Design: data access#
Note
A design document, not a user guide. It records how data.load() grows from “a name that
resolves to a parquet file” into “a name that resolves to a location, which may be a database
table”, and why ChartBook stays a thin layer over the database rather than becoming one.
Status: built for DuckDB on 2026-10-13 (step 5 in Design: start here, “Step 5, as built”; GitHub
issue 9): [sources], table, query, data.sql(), data.ref(), data.connect(), the typed
location in the index, and the worked example. Where what was built departs from this page,
the overview says so and this page keeps its text with a dated note.
Companion to the asset graph, whose rules and “not doing” list apply here.
Revised 2026-10-12: chartbook.data is now the front door for dataframes only. Everything
in this document is about dataframes, so nothing here changed in substance; the wording about
references and about the index was brought up to date.
The problem#
Today a dataframe is a file. data.load() resolves a name to a parquet path and calls
pl.scan_parquet. The manifest has exactly one storage concept, path.
Organizations keep their data in Snowflake, Databricks, Postgres or a DuckDB file, and they already have tools that move it (dbt, Airflow, cron). ChartBook must not become another one. What it can do is let the same name point at a table instead of a file, so that:
data.load("acme/rates", "treasury_rates")keeps working when the parquet moves into a warehouse;a user who wants control can write SQL, referring to dataframes by their ChartBook names, and have the warehouse run it;
the catalog page for a table-backed dataframe looks the same as for a file-backed one.
The rule#
A name resolves to a location. The backend runs the query. Arrow comes back. Polars wraps it.
ChartBook never parses, plans, rewrites or transpiles SQL. It knows where a name lives and
which dialect lives there, and it substitutes identifiers. This is dbt’s source() and ref():
the resolver returns an identifier, and the SQL is the user’s.
Manifest#
A pipeline may declare named sources. A dataframe then points at exactly one of path,
table or query.
[sources.warehouse]
backend = "snowflake"
url = "snowflake://acct/RESEARCH_DB" # the public part only; no credentials, ever
[sources.local]
backend = "duckdb"
url = "duckdb:///./_data/local.duckdb"
[dataframes.treasury_rates]
name = "Treasury Rates"
source = "warehouse"
table = "RATES.TREASURY_RATES" # a table or view that already exists
[dataframes.term_premium]
name = "Term Premium"
source = "warehouse"
query = "select date, dgs10 - dgs2 as tp from RATES.TREASURY_RATES" # a view ChartBook defines
[dataframes.yields_local]
path = "./_data/yields.parquet" # unchanged; the built-in parquet backend, no source needed
Rules:
tableandqueryrequiresource.pathforbids it. Declaring two of the three is an error.urlfollows the SQLAlchemy URL convention (snowflake://,databricks://,postgresql://,duckdb:///). ChartBook does not use SQLAlchemy; the convention is what Polars, Ibis and Kedro already accept, so one string works everywhere.Identifiers are three-level everywhere that matters (
catalog.schema.tablein Unity Catalog and DuckDB,database.schema.tablein Snowflake and Postgres).tablemay give one, two or three parts; the backend fills the rest fromurland quotes for its dialect.
Credentials never enter the repo#
The manifest names a source. The machine supplies the secret, in this order:
environment variable
CHARTBOOK_SOURCE_<NAME>_URLholding a complete URL;~/.chartbook/sources.toml, a table per source name with eitherurlor individual keys (user,password,token,warehouse,role) merged into the manifest URL;the manifest URL alone, which is enough for DuckDB files and for anything the backend’s own client can authenticate (SSO, key pair,
~/.snowflake/connections.toml).
This is dbt’s profiles.yml and Airflow’s conn_id: code names a handle, the environment
resolves it. chartbook check verifies that every source resolves, not that it connects.
Python surface#
Four calls, from easiest to rawest. The first is unchanged.
from chartbook import data as cb
cb.load("acme/rates", "treasury_rates") # -> pl.LazyFrame, as today
cb.sql("select date, rate from {acme/rates:treasury_rates} where date >= '2024-01-01'")
cb.ref("acme/rates:treasury_rates") # -> 'RESEARCH_DB.RATES.TREASURY_RATES'
cb.connect("warehouse") # -> the raw duckdb / snowflake / databricks connection
loadreturns apl.LazyFramefor every backend. For files, Iceberg and Delta it is truly lazy. For a warehouse it is Arrow fetched eagerly and wrapped, because Polars has no lazy database scan. The docstring says so.format="pandas"andformat="polars_eager"keep working.sqlsubstitutes each{ref}with the backend’s identifier. If every reference lives in one source, the query runs there, in that dialect, with that engine’s pushdown. If references span sources, the query runs in DuckDB with each non-DuckDB reference registered as a view or temp table first, and the SQL is DuckDB’s dialect. That is the whole rule; there is no third case. (As built, 2026-10-13: one source per query, plus any files, which DuckDB reads alongside. Two sources are refused. With DuckDB the only engine the mixed case is two DuckDB files, andATTACHthroughconnect()is the honest answer until there is a second engine.)refexists so a user can paste the identifier into SQL they run with their own tooling.connecthands back the client object. Anything ChartBook does not expose is reachable here.
The reference grammar is the one in Design: references: treasury_rates is local,
fed_yield_curve:treasury_rates is another pipeline, ftsfr/fed_yield_curve:treasury_rates is
fully scoped. Every function in chartbook.data takes dataframes only, so a reference that names
another kind (chart/gdp, model/x) is refused, and a {ref} inside sql() must be a
dataframe. Cross-pipeline references need the resolution chain (step 3 in Design: start here); the
first implementation of this design is local-only. (As built: the chain existed first, so a
cross-pipeline file dataframe works in sql() and ref(), as the pinned copy. A table or
query of another pipeline was not pinned at first; since 2026-10-15 a snapshot writes a table or
query in a DuckDB file out as parquet into the store, so it is pinned and read like a file.)
Because sql() puts dataframes’ columns into SQL that the user writes, column names should be
SQL-compatible: letters, digits and underscores, starting with a letter. ChartBook recommends
that for every dataframe; a readable name is metadata, given in a series table
(Design: the index, “Series”).
Two words to keep apart: the kind of an asset (dataframe, chart, …) is part of its
address. The kind of a location (file, table, query) is where a dataframe’s content is,
and is what this document adds to.
Backends#
A backend is the thing behind a source: how to connect, how to quote, how to scan, how to run SQL, how to describe. A source is a configured instance of a backend, named in a manifest.
class Backend(Protocol):
name: ClassVar[str] # "parquet", "duckdb", "snowflake"
def ref(self, loc: Location) -> str: ... # quoted identifier in this dialect
def scan(self, loc: Location) -> pl.LazyFrame: ... # lazy where the engine allows
def sql(self, query: str) -> pl.LazyFrame: ... # run dialect-native SQL, Arrow -> Polars
def connect(self): ... # raw handle
def describe(self, loc: Location) -> Description: ... # columns, row count, last modified
Built in:
parquet(what exists today) andduckdb. DuckDB is the local engine because its relation API returns a realpl.LazyFrame, it reads parquet, Iceberg and Delta, and it attaches Postgres, SQLite and Unity Catalog. It is also the fallback engine for mixed-sourcesql().Plugins, later:
snowflakeanddatabricksusing each vendor’s Arrow fetch,postgresvia ADBC. Package namingchartbook-backend-<name>, entry-point groupchartbook.backends, selected by name frombackend =. Following the asset graph’s rule, the Protocol is extracted into a public contract only once the third implementation exists;duckdbis the second.Optional, never core:
cb.table(ref)returning anibis.Tablefor users who want Ibis’s expression API. Ibis stays an extra. The public return type is Polars.
Backends are selected, not broadcast, so this is a registry keyed by name, not a pluggy hook. The plotting backends stay pluggy because many renderers legitimately answer the same call.
Index consequences#
The Dataframe record carries a typed location object, which for a file
is { "kind": "file", "path", "resolved_path" }. (The index schema was revised on 2026-10-12 to
have this from its first version, so that this design is an addition and not a migration.) A
table-backed dataframe needs:
two more kinds of
location:{ "kind": "table", "source", "backend", "identifier" }and{ "kind": "query", "source", "backend", "sql" };columnspopulated fromdescribe()rather than from reading parquet;an honest hash. A warehouse table cannot be content-hashed cheaply. Record
content_hash: null, hash the definition (identifier or query text plus the described schema), and take freshness from the warehouse’s own metadata (last_altered, ormax(date_col)). The Build record lists the source as impure, exactly as it does for a FRED fetch. Hash what you can, record what you cannot.
Settle the exact field names of the table and query kinds when this is implemented. That a
dataframe’s location is a typed object, not a bare path, is settled, and the index schema shows it.
Vocabulary#
Term |
Means |
Not |
|---|---|---|
backend |
an engine behind a source: parquet, duckdb, snowflake |
“adapter” (dbt’s word for a large dialect shim), “connector” (extract-and-load) |
source |
a named, configured instance of a backend in a manifest |
a credential |
exporter |
one-way emission of the graph to an orchestrator or standard |
“integration” |
plugin |
a pip package that registers backends or exporters via entry points |
a design concept; it is only how code arrives |
Not doing#
No query planning, rewriting or transpiling.
sql()substitutes identifiers and nothing else. If dialect translation is ever wanted, it is an optional, best-effort sqlglot pass, and it is not in this design.No semantic layer. No measures, dimensions, joins or metric definitions in the manifest. A “series” stays a column of a dataframe, as the index schema has it. If a user wants a derived series,
queryis the tool.No writes. ChartBook never creates, loads or alters a warehouse table. Pipelines write with their own tools;
data.path()serves file outputs;tableandquerydescribe what already exists.No credentials in any committed file. Including the catalog’s.
No Ibis as a return type or dependency of the core.
No DuckDB-WASM for data access in the browser. Chart pages embed series inline; anyone who wants to query gets
sql()in Python. Cross-origin parquet in the browser is the problem this design avoids.No per-backend plugin system before the third backend. Rule from the asset graph.
Phasing#
Built (0.2.0).
data.path()anddata.load()call one internal resolver,chartbook.resolve.resolve(), that returns a typedLocation. OnlyFileLocationexists.path()returns aPathfor file locations and raisesTypeErrorfor anything else (resolve.require_file());load()raisesNotImplementedError. This is the seam; it adds no behaviour. To add a location kind: subclassLocation, return it from the resolver, and teachload()to scan it.After the index.
[sources],table,query; theduckdbbackend;sql(),ref(),connect(); local references only; the index’s typedlocation. Worked example: theyield_curveexample pipeline gains onequery-backed dataframe over a DuckDB file. (Built 2026-10-13.)With P1 resolution. Cross-pipeline references in
sql()andref(). (Built with 2, for file dataframes.)On demand.
snowflakeanddatabricksas plugins, then extract the Protocol.
Prior art#
dbt
source()/ref(): a name compiles to a qualified identifier spliced into the user’s SQL. The model forref()andsql().Kedro
ibis.TableDataset:table_nameplusconnection: {backend: duckdb, ...}; one dataset type covers every SQL engine through abackendkey. The model for[sources].Airflow connections and dbt profiles: code names a handle; the environment resolves it. The model for credentials.
DuckDB
relation.pl(lazy=True),ATTACH,read_parquet: the local engine.Polars
read_databaseis eager and there is noscan_database; why warehouse loads are honest about eagerness.Evidence.dev sources: a connection file plus
.sqlfiles in the source’s dialect, each addressable by name from page-level SQL. The model forquery.