QuickSight quirks log¶
Bugs, undocumented behaviors, and silent-failure modes we've hit while building the four shipped dashboards. Each entry captures the observed behavior, the user-visible symptom, the workaround we ship, and the suggested fix on the QuickSight side.
This page exists for two reasons:
- Defect reports. We've collected enough QS-side issues that filing them with the QuickSight team needs a single canonical reference, not bug-by-bug archeology across this repo's commit history.
- Operator survival kit. When a dashboard renders blank or a control behaves oddly, scan this page first — most of the "didn't I just fix that?" moments are a returning instance of one of these classes.
⚠️ Read this first — the worst footgun¶
URL-parameter writes don't sync sheet controls. Filters apply to the data; the control widget keeps showing the OLD value. The text and the chart disagree, and there is no QS-side fix.
Every cross-sheet drill, every embedded deep link, every
CustomActionURLOperation and every SetParametersOperation
that writes a parameter from outside its native picker hits this.
The data filters correctly, the picker / dropdown / date-range
control reads "All" or its baked default, and the analyst sees a
chart that doesn't match the labeled control state. Same defect
fires inside QS's own Navigation Action — it isn't something the
embedding SDK or the JSON shape can route around.
What we've done to minimize the damage
- Cross-app drills (the K.4.7 family) were dropped from Investigation → other apps because the destination's controls could never be made to read right. We kept intra-app drills where the data signal is the contract and the control mismatch is a smaller papercut.
- Sheets that receive a URL-parameter write carry a description paragraph telling analysts "trust the chart, not the control text". The L1 Pending Aging → Transactions drill (v8.5.7) and every cross-sheet drill since carry that callout.
- Drill-write date params snap the destination's date picker visibly to the new value (entry 2.2 below) — that's not a fix for the control-sync defect, it's the price we pay to keep the data + control in agreement on date params specifically. The picker visibly jerking is the lesser evil vs. silently filtering to "no data".
- The L1 cross-sheet drill always writes a wide
[1990, 2099]date range so the destination's universal filter can never narrow the target row out of view (v8.5.7). This combines with 2.3's "static literals only" restriction — there's no way to write "rolling 7 days" via a drill, so we write the widest possible window every time and accept that the picker visibly snaps to it.
If you're considering a new cross-sheet or embed-driven parameter write, assume the destination control will lie. Plan the UX around that. The detailed entries on this defect class are 2.1, 2.2, and 2.3 below.
1. Silent rendering failures¶
1.1 Spinner-forever — entire dashboard stuck, no error surfaced¶
Observed. Every visual on every sheet shows the loading spinner
indefinitely. No error banner, no narrowing-to-zero filter, no
API-level error from describe-dashboard. Datasets describe as
CREATION_SUCCESSFUL and return rows when queried directly through
the QS data-source connection. The database itself responds in
milliseconds.
Diagnostic ladder we use:
- Verify the database returns rows for the underlying SQL via
psycopg2/oracledb— proves the data is there. - Verify
describe_data_setreturnsCREATION_SUCCESSFUL— proves the dataset exists. - Open the dashboard in a fresh incognito window — rules out browser cache.
- Assume QuickSight itself is the broken layer. Either wait it out (sometimes clears on its own) or force a full delete-then-create of the entire QS resource graph (theme, datasource, all datasets, analysis, dashboard) plus a clean re-seed + matview refresh.
Workaround. Capture the diagnostic ladder in CLAUDE.md so we don't keep re-checking the SQL or the data when the data is fine.
Suggested fix. Either surface a useful error to the user when
the QS rendering pipeline stalls, or expose a dashboard health
endpoint so the cause is debuggable without blind delete-then-create.
1.2 KPI silently renders blank with a partially-populated KPIOptions¶
Observed. CreateAnalysis accepts a KPIOptions shape that's
missing a few fields the QS UI always populates. The KPI then
renders as a blank tile in the deployed dashboard — no error at
create time, no warning in the UI. A separately-emitted error
message ("Only PrimaryValueFontSize display property...") shows
up only when emitting certain partial shapes — not all of them.
Workaround. Mirror exactly what QS UI defaults to: emit the
full KPIOptions block with Comparison, PrimaryValueDisplayType,
SecondaryValueFontConfiguration, TargetValues=[],
TrendGroups=[] even when not used. See common/tree/visuals.py
KPI.emit() for the canonical shape (M.4.4.8).
Suggested fix. Document KPIOptions as required (not optional)
on KPIVisual.ChartConfiguration, OR make the API server-side fill
in the missing defaults when CLI sends a partial shape.
1.3 Filter binding to a parameter the analyst can't set silently¶
empties every visual
Observed. A CategoryFilter.with_parameter(...) /
TimeEqualityFilter / NumericRangeFilter (with
minimum_parameter / maximum_parameter) bound to a parameter
that has no sheet control becomes a WHERE clause matches nothing
at runtime. Every visual that depends on the filter renders
empty. No error.
Workaround. Tree validator
(App._validate_filter_param_settability) walks the tree at
construction time and rejects any parameter-bound filter whose
parameter isn't reachable from a sheet control. Catches the bug
class at emit time, not at deploy time.
Suggested fix. QS could surface a "filter parameter is unreachable" warning in the UI when the analyst hovers the empty visual.
2. Drill / parameter-write quirks¶
2.1 URL parameter doesn't sync sheet controls¶
Observed. When a deep-link URL sets a parameter via the
p.<name>=<value> query-string convention, the parameter value
is applied to data filters — but the sheet control widget shows
"All" (or the unmodified default). The control text and the
filtered data disagree. The same defect hits QS's own Navigation
Action — there's no way to re-sync the control text with the
parameter from the embedding side.
Workaround. Per memory project_qs_url_parameter_no_control_sync:
K.4.7 cross-app drills dropped because of this. The sheet
description for affected sheets tells analysts "trust the chart,
not the control text".
Suggested fix. Make p.<name>=<value> URL params propagate
into the sheet control's display value, OR expose a setParameters
method on the embedding SDK that does propagate.
2.2 In-app drill writes to a DateTimeParam snap the picker¶
visibly to the static value
Observed (v8.5.7). SetParametersOperation.ParameterValueConfigurations
that writes a CustomValuesConfiguration.CustomValues.DateTimeValues
to a destination DateTimeParam correctly updates the parameter
AND snaps the destination's picker control to that value. There's
no way to "write the parameter value but leave the picker alone"
or "widen the parameter without changing the picker".
Workaround. When a cross-sheet drill needs the destination's
universal date filter to be wide enough that the target row is
visible (e.g. drilling a stuck-pending leg older than 7 days into
the date-scoped Transactions sheet), we write
pL1DateStart=1990-01-01 and pL1DateEnd=2099-12-31 — the
"all time" sentinel pair. The picker visibly snaps to those values.
Documented as a UX wart.
Suggested fix. Either (a) allow writes that update the param
without re-rendering the picker control, or (b) expose a
SetParameters operation that takes an expression (e.g.
addDateTime(-N, 'DD', truncDate('DD', now()))) so the rolling
default can re-anchor without a static literal showing in the
picker.
2.3 SetParametersOperation only accepts static values or¶
column refs — no now() or rolling-date expressions
Observed. Drill writes to DateTimeParam destinations can only
carry one of: a SourceField reading from a clicked row column, or
a CustomValues.DateTimeValues=[<ISO-8601 literal>] static value.
The RollingDate.Expression shape that ParameterDeclaration.DefaultValues
accepts is NOT accepted as a SetParametersOperation value — there's
no way to write "today minus 7 days" via a drill.
Workaround. Use the static far-past + far-future literals (see 2.2). Authors who want a rolling drill-write would have to build it from the embedding SDK at click time, which defeats the purpose of declarative drill actions.
Suggested fix. Allow RollingDate.Expression as a value type
on SetParametersOperation.
2.4 Sankey right-click drill is non-functional in practice¶
Observed. Wiring a Drill action to a Sankey visual's
right-click trigger emits successfully but the menu either doesn't
appear or doesn't fire on click. Verified across multiple
configurations.
Workaround. Investigation Account Network sheet uses two
separate left-click Sankeys (inbound + outbound) instead of one
bidirectional Sankey with right-click drill. Pattern documented
in walkthroughs/investigation/what-does-this-accounts-money-network-look-like.md.
Suggested fix. Either fix the right-click drill on Sankey or remove the option from the API so it doesn't look supported.
3. Control / widget UX quirks¶
3.1 ParameterDropDownControl only opens on the inner grey bar¶
Observed. The dropdown widget renders a wider visible area than the actual click target. Clicking the visible outer edge of the control does nothing — the popover only opens when the click hits the narrow grey bar in the middle of the control. Confused users assume the dropdown is broken.
Workaround. Documented in sheet descriptions where the
dropdown matters (e.g. Account Network anchor picker). Per memory
project_qs_dropdown_click_target: suggest as the first thing to
check when an analyst reports an "unresponsive dropdown".
Suggested fix. Make the entire control area click-targetable.
3.2 Single-character sheet names are hidden from the rendered¶
tab strip
Observed. Naming a sheet "i" (1-char) makes the tab
invisible in the deployed dashboard's tab strip. The sheet still
exists and is reachable via deep link, but the navigation tab is
gone. Verified against us-east-2.
Workaround. All app-info / canary sheets renamed to a 2+ char
display name (we ship as Info).
Suggested fix. Either drop the implicit 1-char filter or document it.
3.3 Tables virtualize ~10 DOM rows regardless of page size¶
Observed. Even with the table's page size set to a large value (say 10000), the DOM only mounts ~10 rows at a time. Browser-side e2e assertions that count visible rows saturate at 10. This isn't a bug per se, but it's surprising to anyone treating the table as "all rows in the DOM" for assertion purposes.
Workaround. count_table_rows returns DOM-visible (saturates
at ~10). For accurate post-filter counts, use
count_table_total_rows which scrolls + accumulates the true
total. Slower; bumps page size to 10000 and walks the inner
.grid-container.
Suggested fix. Either document the virtualization behavior or expose a "snapshot total row count" property the client can read without scrolling.
3.4 QS holds open WebSocket connections so networkidle never¶
fires
Observed. Playwright's wait_for_load_state('networkidle')
never fires on a deployed QS dashboard because QS holds open
WebSocket / long-polling connections continuously. Naively waiting
for networkidle burns the entire page timeout.
Workaround. Wait on a DOM signal instead — the
[role="tab"] selector attaching is the authoritative
"chrome is up" signal, in practice ~1s after embed-URL load
completes. See wait_for_dashboard_loaded in
common/browser/helpers.py.
Suggested fix. Document the network behavior or expose a "dashboard ready" event the embedding SDK can wait on.
4. Data type / shape quirks¶
4.1 DateDimensionField vs CategoricalDimensionField — column¶
type must match
Observed. A column declared as DATETIME in the dataset
contract MUST be wrapped in a DateDimensionField when used as a
chart Category. Wrapping it in a CategoricalDimensionField (the
default for non-date columns) produces a dashboard that
silently fails to render the visual — the field appears in the
field-well but the chart is blank.
Workaround. Per memory project_qs_date_dimensions: enforced
by the typed Dim.date() factory in common/tree/datasets.py.
The bare Dim() constructor for a date column raises at
construction time.
Suggested fix. Auto-detect the column type from the dataset
contract and pick the right DimensionField subtype, OR raise a
useful error at create-analysis time.
4.2 Conditional formatting expression must guard against the¶
zero-rows case
Observed. A ConditionalFormatting expression like
{column} > 0 (numeric threshold) silently breaks when the table
has zero rows — no error, the table just doesn't apply the
formatting. Empirically the formatting only fires when the
expression contains a "self-equality" guard.
Workaround. Per memory project_qs_conditional_formatting:
always wrap the expression as {col} <> "<sentinel>" so the
expression is always true when the column is non-null. The
formatting then fires unconditionally, and we use the column value
itself for the actual styling decision elsewhere.
Suggested fix. Document the empty-table behavior or fix the expression evaluator to treat zero rows as "format nothing" gracefully.
4.3 DateTimeParam.default is required — UI errors with¶
"epochMilliseconds must be a number, you gave: null"
Observed. A ParameterDeclaration for a DateTimeParameter
that omits DefaultValues deploys cleanly. When the analyst opens
the dashboard, the UI throws "epochMilliseconds must be a number,
you gave: null" — visible only in the JS console — and the
DateTime picker control associated with the param fails to
hydrate.
Workaround. Type-encode in tree/parameters.py:
DateTimeParam.default is REQUIRED (not optional). Attempts to
construct DateTimeParam(default=None) raise at construction
time. See M.4.4.10d.
Suggested fix. Either reject DateTimeParameter declarations
without a default at create-analysis time, or make the picker
hydrate cleanly with a null param value (e.g. show empty until
analyst picks).
4.4 SheetTextBox.Content rejects <br> as a child of <li>¶
Observed. The text-box XML grammar accepts <br/> for line
breaks AND <ul><li>...</li></ul> for bullet lists. Putting one
inside the other — <li>foo<br/>bar</li> — is rejected by the
parser at CreateAnalysis time with
Element 'li' cannot have 'br' elements as children. The error
message names the offending text-box by TextBoxId and the
sheet by SheetId, but no other rich-text element class is
called out (e.g. <li> is fine inside <ul> and <a> is fine
inside <li>). Surfaces silently up to deploy: the JSON
serialises cleanly and the dataset describes cleanly.
Workaround. common/rich_text.py::bullets() post-processes
each item to strip <br>, <br/>, and <br /> (case-insensitive)
and emits a UserWarning per offender. Triggered by L2 YAML
descriptions authored as description: | block scalars: the
embedded \n from human-readable line wrapping reflowed to
<br/> via markdown() and crashed the L1 Drift sheet's
l1-drift-accounts text box — see common/rich_text.py and
tests/json/test_text_box_safety.py::test_no_br_inside_li_in_text_box_content
(v8.5.8).
Suggested fix. Either accept <br/> inside <li> (the most
permissive web-HTML behavior matches most authors' expectations),
or surface the rejection at JSON-validation time (before the
CreateAnalysis round-trip) so callers learn about it without
deploying.
4.5 Chart axis CustomLabel silently ignored without ApplyTo¶
Observed. BarChartConfiguration.CategoryLabelOptions,
ValueLabelOptions, and ColorLabelOptions (and the LineChart
equivalents XAxisLabelOptions / PrimaryYAxisLabelOptions)
each carry a ChartAxisLabelOptions block whose
AxisLabelOptions[].CustomLabel should override the axis title.
Setting only CustomLabel produces a clean CreateAnalysis
round-trip — no error, the JSON describes back identical — but the
deployed chart still renders the raw column name on the axis. The
override is silently no-op.
Workaround. AxisLabelOptions requires an ApplyTo ref
binding the label to a specific field-well leaf
({FieldId, Column: {DataSetIdentifier, ColumnName}}). Same
FieldId-binding pattern table column headers use
(TableFieldOption.FieldId). v8.6.1 added the
_axis_label_apply_to(leaf) helper in common/tree/visuals.py
and wires it into both BarChart and LineChart emit; the class
regression in tests/json/test_bar_chart_axis_labels.py asserts
no CustomLabel escapes without ApplyTo.
Suggested fix. Either reject CustomLabel without
ApplyTo at CreateAnalysis time (loud failure beats silent
no-op), or auto-bind to the first axis field when ApplyTo is
absent.
5. Backend / refresh quirks¶
5.1 Embed URL must be signed by the dashboard's region (not¶
the QuickSight identity region)
Observed. generate_embed_url_for_registered_user called via
a boto3 client constructed in the QS identity region (us-east-1)
returns a URL that, when opened, errors with "We can't open that
dashboard, another QuickSight account or it was deleted" — even
though the dashboard, account, and permissions are all correct.
The error message is misleading: it implies an account/permission
problem when the actual cause is region mismatch.
Workaround. Construct the boto3 client in the dashboard's
region (not the identity region). See
common/browser/helpers.py::generate_dashboard_embed_url —
takes aws_region keyword and constructs the client itself so
callers can't pass the wrong-region client. Burned ~1 hour
debugging this on the M.4.1.i first AWS-side dry-run.
Suggested fix. Either accept identity-region-signed URLs for cross-region dashboards, or surface a "region mismatch" error instead of the misleading "another account" message.
5.2 boto3.client("quicksight") overload set is so large¶
pyright reports "type partially unknown"
Observed (v8.5.2). boto3-stubs[quicksight] typing is
correct, but pyright's overload resolution on boto3.client —
which has Literal-overloads for every AWS service — reports the
return type as "partially unknown" even when the inferred type is
the right QuickSightClient. Two contradictory errors at the same
line.
Workaround. qs: QuickSightClient = boto3.client(...) # pyright: ignore[reportUnknownMemberType]
— targeted suppression at the call site that lets the LHS type
annotation drive downstream inference.
Suggested fix. This is more of a boto3-stubs packaging
concern than QS itself, but flagging because every typed Python
client of the QS SDK hits this.
5.3 Materialized views don't auto-refresh¶
Observed. L1 invariant matviews + Investigation matviews
created via CREATE MATERIALIZED VIEW don't auto-refresh on
underlying-table changes. Every ETL load (and every data apply)
must explicitly call REFRESH MATERIALIZED VIEW. Not a QS bug —
a Postgres / Oracle behavior — but the dashboard reads stale data
silently when the refresh is missed, with no error.
Workaround. quicksight-gen data refresh --execute runs the
refresh in dependency order. CLAUDE.md "Operational Footguns"
section flags this as a footgun.
Suggested fix. Not a QS bug per se. Documenting here because "the dashboard shows old data" is often initially blamed on QS.
How to use this page when filing defects¶
For each issue you want to file with the QuickSight team:
- Find the entry above (or add a new one if it's a new class).
- Reproduce against a minimal hand-built analysis JSON — strip our generator's wrappers down to the smallest dict that triggers the behavior.
- Capture the JSON, the API response (if any), and a screen recording of the misbehavior.
- Cross-reference this page in the report so the QS team can see the workaround context — sometimes the workaround clue helps diagnose the root cause faster than the bare repro.