Catalog API¶
Catalog is the single entry point to the catalog. It wraps a DuckDB
connection and exposes read, write, and query operations on the catalog star schema.
Lifecycle¶
Constructors¶
Create an in-memory store. No data is persisted after the process exits. Useful for tests, scripts, and ephemeral pipelines.
Name a catalog folder at path. Construction is cheap and does not touch the
disk — the DuckDB connection opens on .open() or on entering a with block,
which creates path/bundle.duckdb and path/parquet/ if they do not exist.
Querying an unopened catalog raises RuntimeError.
Pass embedding_model to enable semantic and hybrid search. The model must have
an encode(str) -> list[float] method — any SentenceTransformers, OpenAI, or
Cohere model works. A LanceDB dataset is created at {path}/search/.
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("intfloat/multilingual-e5-small")
store = sc.Catalog("my_catalog", embedding_model=model)
Requires the search optional extra (uv run --extra search or
uv add statcatlib[search]). In-memory stores cannot use embedding_model
(raises ValueError).
Attach to an existing DuckDB connection. The store does not own the
connection — it will not close it on close() or context manager exit. Use
this when sharing a connection with sdmxlib's LocalRegistry:
import duckdb
import sdmxlib as sl
import statcatlib as sc
conn = duckdb.connect("shared.duckdb")
store = sc.Catalog.from_connection(conn)
local_reg = sl.LocalRegistry(conn) # same connection, same tables
Opening¶
Open the connection, creating the catalog folder if it does not exist — open-or-create, and the only spelling. Prefer the context manager, which opens on entry and closes on exit:
An earlier API had Catalog.create(), Catalog.open(path) and
Catalog.open_or_create(path) as classmethods. All three are gone: pass the
path to the constructor instead.
Properties¶
The underlying DuckDB connection. Use for raw SQL queries or to pass to
sdmxlib's LocalRegistry.
Path to the parquet/ directory inside the bundle folder. None for in-memory
stores or connection-mode stores.
Context manager¶
close() closes the DuckDB connection if the store owns it (not in
connection-mode). Safe to call multiple times.
Writing datasets¶
put¶
store.put(obj: BaseDataset | ConceptScheme | list[BaseDataset | ConceptScheme] | tuple[...]) -> None
Add or update a dataset, a SKOS concept scheme, or a list/tuple of either.
If a record with the same identifier/uri already exists it is replaced
(upsert semantics).
Accepted types:
BaseDataset(any variant:OpenDataset,AggregateDataset, etc.)ConceptScheme— upserts the scheme row and cascades to allConceptrowslistortuplecontaining any mix of the above
Examples:
# Single dataset
store.put(sc.OpenDataset(identifier="co2-2024", title=I18n(en="CO2 2024"), description=I18n(en="...")))
# Concept scheme (cascades to concept rows)
store.put(sc.ConceptScheme(
uri="urn:myorg:themes",
label=I18n(en="My Themes"),
concepts={
"ENV": sc.Concept(notation="ENV", label=I18n(en="Environment")),
},
))
# Batch — any mix of dataset kinds and concept schemes
store.put([ds1, ds2, scheme1])
Theme indexing note: put(dataset) writes only the dataset_theme bridge
rows. For theme labels to appear in BM25 search results, store the
ConceptScheme first so the concept table is populated before build_index()
runs.
SDMX structural filters: has_code() and has_dimensions() JOIN through
sdmxlib's structural tables via sdmx_dataflow_urn. These filters only work for
datasets created by ingest_sdmx().
Reading datasets¶
get¶
Direct lookup by identifier. Returns None if:
- no dataset with that
identifierexists, or - the stored
kinddoes not matchcls.
Type narrowing is strict — requesting AggregateDataset for an OpenDataset
returns None.
ds = store.get(sc.AggregateDataset, "ESTAT:NAMA_10_GDP(1.0)")
if ds is not None:
print(ds.sdmx_dataflow_urn)
print(ds.measure_dim)
Use sc.BaseDataset to retrieve without type filtering:
Querying¶
query¶
Start a structured query. Returns a QueryBuilder — no database round-trip
until .execute(), .all(), .first(), .one(), or .count() is called.
search¶
store.search(
text: str,
*,
kinds: Sequence[str] = ("dataset", "concept", "code"),
lang: str = "en",
limit: int = 50,
offset: int = 0,
operator: str = "or",
fuzzy: int = 0,
rrf_k: int = 60,
) -> SearchResult[Hit[Any]]
BM25 full-text search across every indexed entity kind, RRF-merged.
Results are relevance-ranked. Not type-generic — use query(...).search(text)
to narrow by dataset kind.
| Parameter | Description |
|---|---|
kinds |
Entity kinds to search; pass "reference_metadata" explicitly to include methodology sections |
lang |
Which language's index to search. Each language has its own index, so a French query needs lang="fr" — the default "en" will not match French text |
operator |
"or" (default) matches any term; "and" requires all terms |
fuzzy |
Levenshtein edit distance for fuzzy matching: 0 (default), 1, or 2 |
rrf_k |
Reciprocal-Rank-Fusion constant for merging across kinds |
Requires LanceDB (statcatlib[search]). Returns an empty SearchResult when
the LanceDB index has not been built yet; never raises for in-memory or
unbuilt-index stores.
results = store.search("labour force survey", limit=10)
for ds in results.items:
print(ds.identifier, ds.title.get("en"))
# Fuzzy search catches typos and spelling variations
results = store.search("consumr price", fuzzy=1)
Vector and hybrid search — datasets.find¶
store.datasets.find(
query: str, *, mode: str = "bm25", lang: str = "en", limit: int = 50,
offset: int = 0, operator: str = "or", fuzzy: int = 0, rrf_k: int = 60,
) -> SearchResult[Hit[BaseDataset]]
Dataset search with a choice of evidence: mode="bm25" (the default),
"vector" for nearest-neighbour search over the embeddings, or "hybrid",
which merges both rankings with Reciprocal Rank Fusion (k=60 by default).
Each Hit carries the dataset as hit.payload, plus hit.score and
hit.source ("bm25", "vector", or "hybrid" when both matched).
"vector" and "hybrid" encode query with the configured
embedding_model and raise RuntimeError without one. cat.concepts,
cat.codes and cat.reference_metadata have the same find.
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("intfloat/multilingual-e5-small") # 384 dims, bilingual
store = sc.Catalog("my_catalog", embedding_model=model)
# Pure semantic
results = store.datasets.find("people moving between provinces", mode="vector")
# Hybrid — recommended for most queries
results = store.datasets.find("labour market monthly data", mode="hybrid", limit=20)
for hit in results.items:
print(hit.payload.identifier, hit.score, hit.source)
# Hybrid + relational filter via QueryBuilder
import statcatlib.query as q
results = (
store.query(sc.AggregateDataset)
.filter(q.has_code("GEO", "ON"), status="current")
.hybrid_search("unemployment by region")
.limit(20)
.execute()
)
build_index¶
Re-index all datasets for search and optionally build ANN vectors.
If embedding_model is provided it is stored for future vector operations.
Returns the number of datasets embedded.
from sentence_transformers import SentenceTransformer
import statcatlib as sc
model = SentenceTransformer("intfloat/multilingual-e5-small")
store = sc.Catalog("my_catalog").open()
n = store.build_index(model)
print(f"embedded {n} datasets")
After build_index() completes, open the store with embedding_model=model
to use datasets.find(mode="vector") and mode="hybrid".
Requires the search optional extra. Raises ValueError for in-memory stores.
A rebuild is atomic: the new index is written in full and committed in one
step, so an interrupted build_index() leaves the previous index intact and
still serving rather than half-replaced.
index_layout¶
What each language's search index was built with — {lang: {"table", "rows",
"stemmer"}}. Empty when no index has been built.
print(store.index_layout)
# {"en": {"table": "search_en__000001", "rows": 946, "stemmer": "English"},
# "fr": {"table": "search_fr__000001", "rows": 1173, "stemmer": "French"}}
The check worth running after a rebuild: every language you expect is present,
and each names the stemmer you expect. A language missing here is one whose
rows were never tagged with it upstream. A stemmer of None means that
language is indexed unstemmed because no Snowball stemmer exists for it —
weaker, but never wrong the way borrowing another language's stemmer would be.
Unlike embedding_metadata, this is populated for BM25-only catalogs, which
write no embeddings.json at all.
It also doubles as a startup guard. An index written by a newer statcatlib is not readable by an older one, which returns zero hits with no error:
if not store.index_layout:
raise RuntimeError("search index missing or built by a newer statcatlib")
facets¶
Aggregation counts across all datasets for each requested field. Useful for building filter panels in UIs or profiling catalog composition.
Supported fields: "publisher", "themes", "frequency", "status",
"spatial", "type".
counts = store.facets("publisher", "themes", "frequency")
# {"publisher": {"ESTAT": 42, "ECB": 18}, "themes": {"Labour": 22, ...}}
Structural traversal¶
These methods query sdmxlib's structural tables (codelists, DSDs, dataflows) via the shared connection.
code_map¶
Return all dimension→codes for an AggregateDataset. Keys are dimension IDs;
values are lists of code IDs.
codes = store.code_map("ESTAT:NAMA_10_GDP(1.0)")
# {"na_item": ["B1GQ", "P31_S14", ...], "geo": ["AT", "BE", "DE", ...], ...}
codelist_for¶
Return the full sdmxlib Codelist object for a specific dimension of an
AggregateDataset. Returns None if not found.
get_structure¶
Return the sdmxlib DataStructure (DSD) for an AggregateDataset. Returns
None if not found.
registry¶
Return a LocalRegistry backed by the store's DuckDB connection. Use this to
query sdmxlib structural objects (dataflows, codelists, DSDs) directly:
Lineage (PROV-O)¶
add_lineage¶
store.add_lineage(
source_id: str,
target_id: str,
relationship: str,
*,
activity_label: str | None = None,
activity_type: str | None = None,
agent_id: str | None = None,
timestamp: datetime | None = None,
confidence: str = "asserted",
notes: str | None = None,
) -> None
Record a PROV-O-aligned derivation edge from source_id (upstream) to
target_id (downstream). The triple (source_id, target_id, relationship)
is unique — calling again with the same triple updates the other fields (upsert).
relationship vocabulary (see Catalog.LINEAGE_RELATIONSHIPS):
| Value | Meaning |
|---|---|
derived_from |
Generic derivation |
aggregated_from |
Target aggregates source microdata |
adjusted_from |
Seasonal/price/other adjustment |
revision_of |
Target is a revised version |
supplement_to |
Target supplements source |
feeds_into |
Source feeds into target computation |
uses_classification |
Dataset depends on a codelist |
replaces |
Target replaces source (archival) |
confidence vocabulary (see Catalog.LINEAGE_CONFIDENCE):
"asserted" (manual), "extracted" (scraped), "inferred" (LLM).
store.add_lineage(
"lfs-microdata",
"ESTAT:UNE_RT_M(1.0)",
"aggregated_from",
activity_type="aggregation",
activity_label="LFS monthly tabulation",
agent_id="ESTAT",
confidence="asserted",
)
DatasetScope — lineage navigation¶
Lineage traversal and querying is accessed via store.dataset(id):
.lineage_records()¶
store.dataset(id).lineage_records(
*,
role: str = "either",
relationship: str | None = None,
confidence: str | None = None,
) -> list[dict[str, Any]]
Return lineage rows involving the dataset as plain dicts. Each dict has keys:
id, source_id, target_id, relationship, activity_label,
activity_type, agent_id, timestamp, confidence, notes.
Parameters:
role—"source"(dataset is upstream input),"target"(downstream output),"either"(default — both roles).relationship— filter to a specific relationship type.confidence— filter to a specific confidence level.
.upstream()¶
store.dataset(id).upstream(
*,
relationship: str | None = None,
depth: int = 10,
) -> QueryBuilder[BaseDataset]
Recursively traverse source_id links upward. Returns a QueryBuilder over
all ancestor datasets found in the catalog — datasets referenced by lineage edges
but not in the datasets table are silently skipped. Deduplicates — each
dataset appears at most once even with diamond-shaped lineage.
Because this returns a QueryBuilder, you can chain .filter(), .include(),
.sort_by(), .limit(), etc. before materializing:
# Simple traversal
ancestors = store.dataset("ESTAT:UNE_RT_M_SA(1.0)").upstream(depth=5).collect()
# Chain additional operations before executing
publishers = (
store.dataset("ESTAT:UNE_RT_M_SA(1.0)")
.upstream()
.include("publisher")
.scalars("publisher")
.values()
)
.downstream()¶
store.dataset(id).downstream(
*,
relationship: str | None = None,
depth: int = 10,
) -> QueryBuilder[BaseDataset]
Recursively traverse target_id links downward. Returns a QueryBuilder over
all descendant datasets in the catalog.
# Impact analysis: what breaks if CL_GEO changes?
affected = store.dataset("CL_GEO").downstream(relationship="uses_classification").collect()
# Chain filters after traversal
current_descendants = (
store.dataset("lfs-microdata")
.downstream()
.filter(status="current")
.collect()
)
.related()¶
Find datasets sharing an entity via the given edge type. Currently supports
"codelist" — returns a QueryBuilder over datasets that share at least one
codelist with the given dataset.
.codelists¶
Navigate to codelists used by this dataset (lazy scope).
.themes¶
Navigate to themes assigned to this dataset (lazy scope).
Batch label resolution¶
CodelistScope.batch_labels¶
Resolve labels for many codes in a single SQL query. Returns a dict mapping
code_id → label. Falls back to the code ID when the requested language has
no entry. lang defaults to the scope's .lang() setting.
geo_urn = sc.urn.codelist("SDMX", "CL_GEO")
labels = store.codelist(geo_urn).batch_labels(["CA", "US", "DE"])
# → {"CA": "Canada", "US": "United States", "DE": "Germany"}
# Language override via scope
labels = store.codelist(geo_urn).lang("fr").batch_labels(["CA", "US"])
# Falls back to code_id when French label is absent
CodelistsScope.batch_labels¶
store.codelists.batch_labels(
pairs: list[tuple[str, str]],
lang: str | None = None,
) -> dict[tuple[str, str], str]
Resolve labels across multiple codelists in one query. Accepts a list of
(codelist_urn, code_id) tuples and returns a dict mapping each pair to its
label.
pairs = [
(sc.urn.codelist("SDMX", "CL_GEO"), "CA"),
(sc.urn.codelist("SDMX", "CL_GEO"), "US"),
(sc.urn.codelist("SDMX", "CL_FREQ"), "A"),
]
labels = store.codelists.batch_labels(pairs, lang="en")
# → {("urn:...CL_GEO...", "CA"): "Canada",
# ("urn:...CL_GEO...", "US"): "United States",
# ("urn:...CL_FREQ...", "A"): "Annual"}
QueryBuilder¶
QueryBuilder[T] is returned by store.query(). All methods return self
for chaining.
.filter¶
Add one or more filter conditions (ANDed together). Accepts:
- keyword arguments for flat column filters (
publisher,status,frequency,identifier) PositionalFilterobjects fromstatcatlib.query:has_code,has_dimensions,distribution,has_service,publishes,serves(and anyExistsFiltersubclass)MapFilterobjects:has_spatial,has_keyword,title_contains,description_contains
Multiple calls to .filter() are also ANDed.
.search¶
Add BM25 full-text search. Combines with any .filter() conditions.
| Parameter | Description |
|---|---|
operator |
"or" (default) matches any term; "and" requires all terms |
fuzzy |
Levenshtein edit distance for fuzzy matching: 0 (default), 1, or 2 |
.hybrid_search¶
Add hybrid BM25 + vector search (RRF). Requires embedding_model on the store.
Relational filters from .filter() are applied on top of the RRF-merged result.
import statcatlib.query as q
results = (
store.query(sc.AggregateDataset)
.filter(status="current")
.filter(q.has_code("GEO", "ON"))
.hybrid_search("unemployment trends")
.limit(20)
.execute()
)
.sort_by¶
Sort by a flat column. Valid fields: identifier, status, frequency_id,
issued, modified.
.limit / .offset¶
Pagination controls.
.with_facets¶
Request facet counts alongside the result. Same fields as store.facets().
Counts will reflect the filtered result set, not the whole catalog.
.include¶
Declare which relationships to batch-load during hydration. By default all
relationships are loaded (~17 queries per list call). Calling .include()
switches to selective loading — only the named relationships are fetched;
everything else is set to an UNLOADED sentinel.
Multiple .include() calls accumulate. Use .full() to revert.
# Load only publisher and themes — 3 queries instead of ~17
store.query(sc.BaseDataset).include("publisher", "themes").all()
# Accumulate across calls
store.query(sc.BaseDataset).include("publisher").include("themes", "keywords").all()
Unloaded relationships are set to a special UNLOADED sentinel (not None or
[]). Accessing any attribute, iterating, taking len(), or indexing an unloaded
field raises NotLoadedError:
ds = store.query(sc.BaseDataset).include("publisher").first()
ds.publisher.name # OK — publisher was included
ds.themes # returns UNLOADED sentinel (falsy, no raise)
ds.themes[0] # raises NotLoadedError: 'themes' was not loaded
list(ds.keywords) # raises NotLoadedError: 'keywords' was not loaded
# Check before accessing
if not sc.is_unloaded(ds.themes):
print(ds.themes)
NotLoadedError is a subclass of AttributeError. sc.is_unloaded(value) returns
True for any UNLOADED sentinel. Both are in statcatlib.__all__.
Valid relationship names (matching model field names):
| Name | Field type |
|---|---|
publisher |
Agent \| None |
contact_point |
ContactPoint \| None |
frequency |
Frequency \| None |
licence |
LicenceDocument \| None |
themes |
tuple[ConceptRef, ...] |
subject |
tuple[ConceptRef, ...] |
dataset_type |
ConceptRef \| None |
keywords |
list[InternationalString] |
spatial_coverage |
list[SpatialCoverage] |
distributions |
list[Distribution] |
conforms_to |
list[Standard] |
quality_annotations |
list[QualityAnnotation] |
provenance |
list[ProvenanceStatement] |
dimension_names |
list[InternationalString] (AggregateDataset) |
variables |
list[Variable] (MicrodataDataset) |
feature_types |
list[str] (GeospatialDataset) |
authors |
list[str] (PublicationDataset) |
Raises ValueError for unrecognised names.
.full¶
Load all relationships (the default behaviour). Useful to explicitly opt into
full hydration, or to reset after a partial .include() chain:
Note: store.get() always loads all relationships regardless of .include().
Terminal methods¶
Entity terminals (default — return domain objects):
.execute() -> SearchResult[T] # run query; return items + total + facets
.collect() -> list[T] # just the items (preferred)
.all() -> list[T] # alias for .collect()
.first() -> T | None # limit(1).collect()[0] or None
.one() -> T # assert exactly 1 result
.count() -> int # total count
.exists() -> bool # True if at least one match
Row terminals (require .select() — return Row projections):
Scalar terminals (require .scalars() — return bare values):
Calling an entity terminal (.collect(), .first(), etc.) when .select()
or .scalars() is active raises ValueError — use .rows() / .values()
instead.
Projection modifiers¶
Row¶
Row is a dict subclass that also supports attribute-style field access:
rows = store.query(sc.BaseDataset).select("identifier", "status").rows()
row = rows[0]
row["identifier"] # dict-style access
row.identifier # attribute-style access — same value
isinstance(row, dict) # True — fully dict-compatible
# Missing attribute raises AttributeError
row.nonexistent # AttributeError: Row has no field 'nonexistent'
Row is exported as sc.Row.
.select¶
Set row projection mode. Use .rows() or .row() as the terminal.
A single SQL query is issued — no store.get() call per row.
Selectable columns: identifier, kind, status, issued, modified,
landing_page, version (flat), title, description (MAP — require lang).
rows = (
store.query(sc.BaseDataset)
.filter(status="current")
.sort_by("title", lang="en")
.select("identifier", "title", lang="en")
.rows()
)
# → [Row({"identifier": "EXR", "title": "Exchange Rates"}), ...]
rows[0].identifier # "EXR"
rows[0]["title"] # "Exchange Rates"
Raises ValueError for unrecognised columns or MAP columns missing lang.
.scalars¶
Set scalar projection mode. Use .values() or .value() as the terminal.
Single SQL query.
ids = store.query(sc.BaseDataset).filter(status="current").scalars("identifier").values()
# → ["EXR", "M1", "UNEMP", ...]
titles = store.query(sc.BaseDataset).scalars("title", lang="en").values()
# → ["Exchange Rates", "Money Supply", ...]
SearchResult¶
@define
class SearchResult(Generic[T]):
items: list[T]
total: int
offset: int = 0
facets: dict[str, dict[str, int]] = Factory(dict)
scores: dict[str, float] = Factory(dict)
items— the dataset objects for the current pagetotal— total matching datasets (may exceedlen(items)when paginating)offset— the offset this page starts atfacets— populated when.with_facets()was used; otherwise empty dict