Time Axes¶
dbt Charts auto-detects the chronological grain of date columns and renders bucketed-time axes as ordinal (evenly-spaced category bands) by default. Bar, line, and area charts all follow the same rule: the band scale gives bars their full width, aligns line dots with bar centers, and keeps support-table column spacing uniform, without any per-chart-type patches.
You can override the detected grain, use explicit time-part units, disable bucketing entirely, or opt back into a continuous temporal scale for the rare cases that need it.
For how time-axis labels themselves are laid out (when they tilt, when they drop, when a continuous time axis bar chart stays vertical even when crowded), see Axis Labels: Smart Layout.
Auto-detection¶
When x is a date/datetime column on a bar, line, or area chart,
dbt Charts inspects the distinct values and picks a grain via two coexisting
detection paths: calendar-date predicates (for ISO date/datetime values) and
labeled string patterns (for period key strings). Both paths produce a VL
timeUnit; the column must use one path exclusively; mixed shapes error.
Calendar-date predicate path¶
| Detected grain | Recognized when |
|---|---|
year |
All distinct values fall on January 1, or sit whole years apart |
yearquarter |
All fall on the 1st of Jan / Apr / Jul / Oct, or sit whole quarters apart |
yearmonth |
All fall on the 1st of any month, or sit whole months apart |
yearweek |
All fall on the same weekday, a week or two apart |
yearmonthdate |
All values are at midnight and no coarser cadence fits (daily) |
| (none, continuous) | Any value has nonzero hours/minutes/seconds |
Spacing counts as much as position: month-end closes, quarter-end closes and invoices dated the 15th all name their real grain even though no value lands on a bucket start. A cadence is only accepted while each value still gets a bucket to itself: one off-cadence date among month-end snapshots drops the series back to daily rather than folding two rows into one month.
Labeled period-key path¶
Warehouse exports often return period keys as strings rather than dates. dbt Charts recognizes these stable shapes directly:
| Pattern | Example | Detected grain |
|---|---|---|
YYYY-Www (ISO week-year) |
2024-W32 |
yearweek |
W[eek ]N YYYY |
W32 2024, Week 32 2024 |
yearweek |
YYYY-Qn (canonical quarter) |
2024-Q3 |
yearquarter |
Qn YYYY |
Q3 2024 |
yearquarter |
YYYYQn |
2024Q3 |
yearquarter |
YYYY-MM |
2024-01 |
yearmonth |
Mon YYYY (English month) |
Jan 2024 |
yearmonth |
MM/YYYY (US month/year) |
01/2024 |
yearmonth |
FYnnnn (fiscal year key) |
FY2024 |
year (mapped to Jan 1) |
MM/DD/YYYY (US month/day/year) |
01/15/2024 |
yearmonthdate |
Mon DD[,] YYYY |
Jan 15, 2024 |
yearmonthdate |
Scope: These patterns detect calendar quarters (Q1 = Jan–Mar, Q2 = Apr–Jun,
Q3 = Jul–Sep, Q4 = Oct–Dec) and always anchor FY strings' year grain at
Jan 1. To author a fiscal year/quarter that starts in a different month, set
style.axis_x.fiscal_year_start_month; see
Fiscal-quarter offset below.
US-only formats: MM/YYYY and MM/DD/YYYY are parsed as US month/day order.
European day-first ambiguity is not detected; set time_unit explicitly if your
warehouse returns day-first slash-delimited dates.
Not supported: H1 YYYY / YYYY-H1 half-year strings (no Vega-Lite
timeUnit for half-year buckets). These are treated as unparseable; use
time_unit: year explicitly.
Year-shape path (bare year INTEGER or YYYY string)¶
dbt marts routinely store the year as a plain INTEGER (launch_year) or a
display VARCHAR ("2024") rather than a date. dbt Charts detects a year-shaped
x column: every non-null value is an integer in 1900–2100, or a string
matching ^\d{4}$ in that range, and treats it as the year grain, exactly as
if you had cast it to a DATE. No make_date(...) cast and no
axis_x.time_unit are needed:
- On a
barchart the years render as evenly-spaced vertical bands with%Y("2024") tick labels; no~sSI suffix ("2.02k") and no auto-flip to horizontal that a plainVARCHARcategory would trigger. - On a
line/areachart they render on a continuous temporal scale (see the scale-type section below).
The detection is value-only; the column name is never consulted, so a
revenue_bucket column holding 2010, 2011, … is treated the same as one named
year. Non-year integers (counts, ids, ratings) stay quantitative. An authored
axis_x.type or axis_x.scale.continuous.type always overrides the inference.
Emitted scale type¶
Each detected bucket becomes an evenly-spaced band on an ordinal x-axis for
bar/column charts; line/area charts emit a continuous temporal scale
(a trend line reads over real time). Bar width, line-dot alignment, and
support-table column spacing all follow band geometry automatically. Either
default is overridden by an authored axis_x.type.
Authoring¶
Override the detected grain or disable bucketing via style.axis_x.time_unit:
charts: monthly_revenue: type: bar x: month y: revenue style: axis_x: time_unit: yearmonth # explicit override
Valid values:
| Value | Meaning |
|---|---|
auto |
Default; auto-detect from data |
year |
Force year bucketing |
yearquarter |
Force quarter bucketing |
yearmonth |
Force month bucketing |
yearweek |
Force ISO-week bucketing |
yearmonthdate |
Force daily bucketing |
monthofyear |
Extract month-of-year (Jan ... Dec) |
dayofweek |
Extract day-of-week |
dayofmonth |
Extract day-of-month |
dayofyear |
Extract day-of-year |
hourofday |
Extract hour-of-day |
none |
Disable bucketing; continuous temporal scale |
dbt Charts uses long-form names for time-part units, then maps them to Vega-Lite
primitives at emission time: monthofyear -> month, dayofweek -> day,
dayofmonth -> date, dayofyear -> dayofyear, and hourofday -> hours.
Bucketing places rows; it does not aggregate them¶
A row belongs to the bucket its date falls inside, so a period reported at its
last instant (LAST_DAY(month) month-ends, quarter-ends, a fixed day of the
month) plots in its own bucket and needs no change to your SQL.
What a coarser grain will not do is combine rows. Two rows inside one bucket
(monthly rows under yearquarter, daily rows under yearmonth) have no single
value to plot, so dbt Charts raises
ERR-GAP-FILL-BUCKET-COLLISION rather than picking
one and discarding the rest. Aggregate to the grain in the query.
Fiscal-quarter offset¶
year and yearquarter bucketing default to the calendar convention (year
starts January, Q1 = Jan–Mar). Businesses whose fiscal year starts in a
different month set style.axis_x.fiscal_year_start_month (1=Jan..12=Dec):
charts: fiscal_quarterly_revenue: type: bar x: quarter y: revenue style: axis_x: time_unit: yearquarter fiscal_year_start_month: 4 # fiscal Q1 = Apr-Jun
The offset shifts bucket boundaries, Q1..Q4 numbering, and the axis label's
year-boundary line break together; there is no separate "fiscal" mode to
keep in sync. It applies on both ordinal (bar, or line/area with
curve: step-band) and continuous-temporal (line/area) scales: at a
non-default offset, dbt Charts always buckets fiscally in Python rather than
handing the field to Vega-Lite's native timeUnit, since Vega-Lite's own
timeUnit transform has no fiscal-offset concept and would silently
re-bucket to calendar-aligned boundaries.
Scale type: default depends on mark¶
For a bucketed-calendar grain the default scale type depends on the mark:
bar/column default to ordinal (evenly-spaced bands, one per bucket);
line/area/scatter default to temporal (a continuous time scale so a
trend reads over real calendar time and irregular gaps are preserved, and a
scatter point sits at its real position rather than snapping to a band).
The bar/column default is a starting point, not a guarantee: a band axis
owes one slot per bucket across the whole span, so on data that is sparse for
its detected grain those bands become an unreadable comb. Past a density
budget (chart_rendering.type_inference.max_ordinal_buckets, default 60) an
auto-detected grain is dropped and the axis resolves temporal. That holds for
every grain detection picks on its own, coarse or fine: twelve readings a decade
apart honestly detect year, yet would owe the axis ten empty year bands for
every bar. line/area/scatter are never re-typed this way, since they stay
temporal regardless of density, but an over-budget grain is still dropped for
them too. The axis paints one gridline and label per bucket whether or not the
mark bands to it, so a sparse grain falls back to Vega-Lite's own
continuous-temporal ticks rather than a solid gray stripe of gridlines. An
authored time_unit is never dropped.
If a bar chart renders on a continuous time axis you did not ask for, this is
why — pin it with axis_x.type: ordinal, or coarsen the grain in the query and
name it with axis_x.time_unit (a coarser time_unit alone will not combine
the rows; see above).
Override either with axis_x.type:
style: axis_x: type: temporal # force a continuous time scale (for example, on a bar chart)
axis_x.type |
Scale | When to use |
|---|---|---|
auto (default) |
Ordinal for bar/column unless the data is too sparse for its grain, then temporal; always temporal for line/area/scatter |
The common case |
ordinal |
Ordinal (explicit) | Force bands on a line/area/scatter, or document bar intent |
temporal |
Continuous temporal | Force a time scale on a bar; irregular sampling |
time_unit: none always produces a continuous temporal scale regardless of
axis_x.type.
Heatmap's x is always a nominal grid dimension (one band per column, regardless of mark or bucket count) so it sits outside this ordinal/temporal choice entirely. It still gets the same label vocabulary and visibility thinning described above and in Axis Labels: the domain value stays the raw bucket key (so cells keep chronological order), and only the painted label text follows the calendar cadence.
Extending the visible range past the data: scale.continuous.type + scale.continuous.domain¶
axis_x.scale.continuous.type: temporal is a second spelling of the same escape hatch,
for authors who reach for the scale block rather than the axis-level type
field. It's required before an authored scale.continuous.domain of ISO dates can pin
the visible range past the data extent: a domain only means anything on a
continuous scale:
style: axis_x: scale: continuous: type: temporal domain: ["1955-01-01", "2026-01-01"] # pads the axis before/after the data
Authoring scale.continuous.domain on a date axis without scale.continuous.type: temporal
(or axis_x.type: temporal) raises a compile error rather than silently
collapsing every mark: Vega-Lite reads a 2-element domain on the default
ordinal (band) scale as exactly two category values, not a [low, high]
range, so every mark lands on the domain's first "category."
Tick cadence: step-anchored time_unit + step¶
style.axis_x.ticks.count (see Axis Labels) asks Vega-Lite
for a target number of ticks, a plausible-sounding integer that VL treats
as advisory, and that has no relationship to your domain's actual calendar
width. Reaching a clean "every 5 years" cadence with count means guessing
which integer happens to produce 5-year spacing on your particular date
range.
style.axis_x.ticks.time_unit (with an optional step) names the cadence
directly instead: "ticks at every 5 years, anchored on multiples of 5",
the same way an author says "quarterly" or "every other month".
style: axis_x: ticks: time_unit: year step: 5 # every 5 years; 1900, 1905, 1910, ...
time_unit alone (no step) is equivalent to step: 1; one tick per unit.
dbt Charts maps the calendar grain to Vega-Lite's native axis.tickCount:
{interval, step} (year → year, yearquarter → quarter, yearmonth →
month); d3 (VL's tick engine) anchors ticks on multiples of step,
including odd steps, with no date materialization on dbt Charts's side.
Constraints:
time_unitacceptsyear,yearquarter, oryearmonthonly: the grains whose mapped d3 interval (year/quarter/month) anchors cleanly. Day/week cadences give irregular spacing (d3's own documented caveat) and are out of scope.- Continuous temporal
axis_xonly. The axis must resolve to a continuous temporal scale (see Scale type above); an ordinal bucketed axis (for example, a low-density monthly bar chart) has no VL tick-count concept to pass this through to, and raises rather than silently doing nothing. - On a temporal axis,
steprequirestime_unit. A bare number names no calendar unit, so a temporal axis raises rather than guessing one. (On a quantitative x-axis a barestepis the numeric tick interval; see Axis & scales.) - Mutually exclusive with
ticks.count. They are two different ways to express tick cadence; author one or the other, not both. axis_yis rejected. The measure axis is never temporal in dbt Charts's cartesian model.- No anchor override. Ticks always anchor on multiples of
step(d3's own default); there is no field to shift the anchor off that grid. - Endpoint control belongs to
labels.values(see Axis Labels) ; this surface only controls tick/grid density, not which labels render.
queries:
index_series:
columns: [year, index]
values:
- ["2006-01-01", 245.0]
- ["2007-01-01", 251.0]
- ["2008-01-01", 260.4]
- ["2009-01-01", 265.9]
- ["2010-01-01", 272.0]
- ["2011-01-01", 279.0]
- ["2012-01-01", 282.2]
- ["2013-01-01", 288.6]
- ["2014-01-01", 296.4]
- ["2015-01-01", 306.1]
- ["2016-01-01", 308.7]
- ["2017-01-01", 313.5]
- ["2018-01-01", 316.1]
- ["2019-01-01", 326.6]
- ["2020-01-01", 336.2]
- ["2021-01-01", 338.3]
- ["2022-01-01", 351.6]
- ["2023-01-01", 365.3]
- ["2024-01-01", 375.5]
- ["2025-01-01", 385.4]
charts:
index_line:
query: index_series
type: line
title: Index
x: year
y: index
style:
axis_x:
ticks:
time_unit: year
step: 5
rows:
- index_line
Gap handling: synthesized rows for missing buckets¶
Ordinal scales only render bands for values that exist in the data. If your query
returns months Jan, Mar, and Apr (February skipped because there were no
events), Vega-Lite silently drops the February slot and the resulting chart
looks like Jan → Mar → Apr with equal spacing, indistinguishable from a
chart where February genuinely follows January.
When the engine detects a bucketed-calendar grain (year, yearquarter,
yearmonth, yearweek, yearmonthdate) on an ordinal x-axis, it
automatically synthesizes rows for every missing bucket between the minimum
and maximum date in the data window, ensuring every slot is rendered.
The fill field on axis_x controls what value is inserted for
synthesized rows:
| Value | Effect |
|---|---|
"null" |
Missing measure columns receive null. Lines and areas break at null. Theme default. Use a quoted string in chart-local YAML to pin this mode; bare fill: null means cascade-inherit. |
zero |
Missing measure columns receive 0. Event-count charts where a missing bucket means zero events. |
linear |
Straight-line values between observed neighbors (interior gaps only). |
step-after |
Looker Step (after): forward-fill from the last observed bucket. |
step-before |
Looker Step (before): each gap takes the next observed bucket’s value. |
step-center |
Looker Step (center): hold left value until the gap midpoint, then the right value. |
curve |
Smooth S-curve (smoothstep) between neighbors; softer than linear, same endpoints. |
linear, step-*, and curve fill interior synthetic buckets only; each color
/ stack series is filled independently.
Scatter default: style.charts.axis_x.fill is the global default for
bar/line/area. The theme pins style.charts.scatter.axis_x.fill: null so that
a theme-level change to the global fill does not connect gaps on point
charts. An explicit fill you author yourself, board-wide
(style.charts.axis_x.fill) or on a single chart (style.axis_x.fill), still
takes effect on scatter, so set it per-chart or per-family if you want scatter
to keep its honest, ungap-filled dots.
charts: weekly_signups: type: line x: week y: signups style: axis_x: fill: "null" # explicit null-fill override (bare null = cascade-inherit; quote to pin) weekly_orders: type: bar x: week y: order_count style: axis_x: fill: zero # missing weeks show as zero-height bars monthly_revenue_on_weekly_grid: type: area x: week y: revenue style: axis_x: fill: linear # smooth bridge across sparse samples
Multi-series charts: The engine cross-joins every bucket in the window
with every dimension value observed in the data. "Observed" means only
values that appear in the query result; if a new dimension value appears
partway through the window, synthetic rows before its first appearance
carry null/0 measures for that value only.
Escape hatches: Gap-fill is skipped entirely when:
axis_x.type: temporal(continuous scale; VL handles irregular gaps visually)time_unit: none(non-bucketed continuous axis)- A non-bucketed grain is detected (
monthofyear,dayofweek, etc.)
Supported source formats per grain¶
year¶
| Source format | Example |
|---|---|
| ISO date at Jan 1 | 2024-01-01 |
Python date object |
date(2024, 1, 1) |
| Fiscal-year key | FY2024 (mapped to Jan 1) |
Recommended SQL: date_trunc('year', date) AS year (produces ISO dates for
auto-detection). EXTRACT(YEAR FROM date) returns an integer column which is
typed quantitative, not temporal.
yearquarter¶
| Source format | Example |
|---|---|
| ISO date at quarter start | 2024-01-01, 2024-04-01, 2024-07-01, 2024-10-01 |
| Canonical quarter label | 2024-Q1, 2024-Q2, 2024-Q3, 2024-Q4 |
| Quarter-first label | Q1 2024, Q2 2024 |
| Compact quarter label | 2024Q1, 2024Q2 |
Recommended SQL: date_trunc('quarter', date) AS quarter (produces ISO
dates), or CONCAT(YEAR, '-Q', QUARTER) AS quarter for labeled exports.
yearmonth¶
| Source format | Example |
|---|---|
| ISO date at month start | 2024-01-01, 2024-02-01, … |
Python date at month start |
date(2024, 1, 1) |
| Year-month string | 2024-01, 2024-12 |
| English month-name string | Jan 2024, February 2024 (3-letter abbreviation only) |
| US month/year slash | 01/2024, 12/2024 |
Recommended SQL: date_trunc('month', date) AS month
Display: Smart default applies %b %Y → Jan 2024 on the ordinal axis via
axis.formatType: "time". Override with style.axis_x.labels.format.
yearweek¶
| Source format | Example |
|---|---|
| ISO date at week start (Monday) | 2024-01-01, 2024-01-08, … |
| ISO week-year label | 2024-W01, 2024-W32 |
| Spelled week label | W32 2024, Week 32 2024 |
Recommended SQL: date_trunc('week', date) AS week (produces ISO dates), or
CONCAT(ISO_YEAR, '-W', LPAD(ISO_WEEK, 2, '0')) AS week for labeled exports.
Note: ISO week-start is Monday. US Sunday-start weeks are out of scope.
Default label cadence: Measured. On a bucket-aligned column chart with room for them, every week is labeled with the day of month it starts on. The three-letter month appears on a second row under the first week of each month:
The first visible label always carries the year. On continuous line and area axes, Vega's weekly scale positions ticks on Sunday; dbt Charts labels each tick with the represented ISO Monday bucket without moving the tick. When the day numbers no longer fit, labels promote to month text and default ticks follow the month openers. Further thinning can show fewer month labels without removing ticks. The weekly encoding grain remains unchanged throughout.
yearmonthdate (daily)¶
| Source format | Example |
|---|---|
| ISO date | 2024-01-15 |
| ISO datetime at midnight | 2024-01-15T00:00:00 |
Python date / datetime (midnight) |
date(2024, 1, 15) |
| US month/day/year slash | 01/15/2024 |
| English day-name string | Jan 15, 2024, Jan 15 2024 |
Recommended SQL: date::DATE or date_trunc('day', date)
Default label cadence: A short bucket-aligned column chart labels every day with its day of month and puts month context on a second row. As the labels stop fitting, the cadence steps first to Mondays (still shown as day numbers) and then to month text. Default ticks follow a format promotion, but later visibility-only thinning leaves them alone.
Time-Part Units¶
Use time-part units when the question compares a recurring calendar part rather than elapsed time. The x column should still be a real date or timestamp; Vega- Lite extracts the requested part. Time-part units remain on a temporal scale (they represent cyclic comparison, not ordered buckets).
charts: revenue_by_month_of_year: type: bar x: order_date y: revenue style: axis_x: time_unit: monthofyear orders_by_day_of_week: type: bar x: order_ts y: order_count style: axis_x: time_unit: dayofweek
time_unit: auto does not infer time-part intent from field names. If you want
seasonality, day-of-week, or hour-of-day behavior, author the time part
explicitly.
Failures¶
dbt Charts fails loud (raises an error) when:
- ≥10% of distinct values are unparseable as dates or recognized label patterns. Fix the query or cast the column.
- A column contains mixed shapes, for example, some values are
2024-W32(week labels) and others are2024-01-15(ISO dates), or week labels alongside quarter labels. Setstyle.axis_x.time_unitexplicitly for ambiguous columns.
Use style.axis_x.time_unit: none for raw event data with sub-daily
timestamps; detection returns continuous temporal automatically, but the
explicit override documents intent.
Axis labels¶
style.axis_x.time_unit controls the data bucket sent to Vega-Lite.
style.axis_x.labels.time_unit controls label vocabulary when authored.
Automatic label vocabulary can adapt to the domain and chart width; the
encoding time unit never changes.
style: axis_x: time_unit: yearmonth labels: time_unit: yearquarter
When labels.time_unit is omitted, dbt Charts measures bucket-aligned daily and
weekly labels against the available width. Daily labels try every day, then
Mondays, then month text. Weekly labels try every week, then month text. The
encoding grain and chart data remain unchanged, while default ticks follow a
promoted display grain at the first source bucket that opens each period.
Continuous line and area axes remain continuous; weekly ticks still use the
compact Monday-bucket label described above. An authored label time unit pins
its vocabulary regardless of width.
An authored labels.time_unit: yearquarter uses Q1–Q4, including fiscal
quarter numbering, and makes quarter boundaries the default tick cadence. On a
continuous temporal axis, an authored axis_x.ticks.count or
axis_x.ticks.interval overrides that default.
Label density after display-grain selection is a separate render-time
decision. When labels do not fit, dbt Charts skips labels while preserving the
resolved vocabulary. Monthly labels therefore thin to fiscal-quarter openers:
Jan/Apr/Jul/Oct for a January fiscal year, or
Mar/Jun/Sep/Dec for a March fiscal year. Width-driven visibility
thinning does not change ticks or gridlines.
If the thinned labels still do not fit, dbt Charts tries the configured
labels.tilt_increments. The order is always skip, then tilt, and the existing
labels.overlap.skip and labels.overlap.tilt switches control those steps.
The fiscal opener is the phase anchor. The first visible label always includes
the year, even when that label is not itself a fiscal-year opener.
Set labels.time_unit: none to disable dbt Charts's smart label expression and
let Vega-Lite format the axis.
A date axis already labels itself by grain (Jan 2024 for yearmonth, and so
on), so set style.axis_x.labels.format only when you want a different form
than the grain default. Here, numeric 01/2024 instead of Jan 2024:
style: axis_x: time_unit: yearmonth labels: format: "%m/%Y"
For label-density tuning (when monthly labels feel too cramped even without bounding-box overlap) see Label density.