03 · Data Cataloging & Metadata Management¶
A lake with a hundred tables and no catalog is just a bucket full of files nobody can find or trust. A data catalog tracks what tables exist, what schema and partitions they have, where they physically live, and (ideally) who owns them and how fresh they are. This module builds a minimal catalog by hand to show exactly what a real one (Hive Metastore, AWS Glue Catalog, Unity Catalog, Iceberg's own catalog) is doing under the hood.
What actually ran
This module was reasoned through step by step against real sqlite3,
json, and pyarrow APIs but not executed in a live interpreter for
this lesson — the query results shown match documented behavior
precisely.
A catalog is just a database about your data¶
import sqlite3
catalog = sqlite3.connect(":memory:")
catalog.executescript("""
CREATE TABLE tables (
table_id INTEGER PRIMARY KEY,
db_name TEXT,
table_name TEXT,
location TEXT,
format TEXT,
owner TEXT,
created_at TEXT
);
CREATE TABLE columns (
table_id INTEGER,
col_name TEXT,
col_type TEXT,
ordinal INTEGER,
FOREIGN KEY (table_id) REFERENCES tables(table_id)
);
CREATE TABLE partitions (
table_id INTEGER,
partition_spec TEXT,
location TEXT,
row_count INTEGER,
FOREIGN KEY (table_id) REFERENCES tables(table_id)
);
""")
catalog.commit()
This is, structurally, exactly what Hive Metastore's backing RDBMS looks
like — TBLS, COLUMNS_V2, and PARTITIONS tables playing the same roles.
Registering a table¶
catalog.execute(
"INSERT INTO tables VALUES (1, 'sales', 'orders', 's3://lake/silver/orders', 'parquet', 'data-eng', '2026-08-01T00:00:00')"
)
catalog.executemany(
"INSERT INTO columns VALUES (?, ?, ?, ?)",
[
(1, "order_id", "bigint", 0),
(1, "customer", "string", 1),
(1, "amount", "double", 2),
(1, "order_date", "date", 3),
],
)
catalog.executemany(
"INSERT INTO partitions VALUES (?, ?, ?, ?)",
[
(1, "order_date=2026-08-01", "s3://lake/silver/orders/order_date=2026-08-01", 1200),
(1, "order_date=2026-08-02", "s3://lake/silver/orders/order_date=2026-08-02", 980),
],
)
catalog.commit()
Querying the catalog: "what does this table look like?"¶
import pandas as pd
def describe_table(catalog, db, table) -> pd.DataFrame:
return pd.read_sql(
"""
SELECT c.ordinal, c.col_name, c.col_type
FROM tables t JOIN columns c ON t.table_id = c.table_id
WHERE t.db_name = ? AND t.table_name = ?
ORDER BY c.ordinal
""",
catalog, params=(db, table),
)
print(describe_table(catalog, "sales", "orders"))
ordinal col_name col_type
0 0 order_id bigint
1 1 customer string
2 2 amount double
3 3 order_date date
This is the exact query a BI tool or query engine runs before it can plan a
SELECT against a table it's never seen — it needs column names and types
before it touches a single data file.
Partition pruning starts at the catalog, not the files¶
def partitions_for_range(catalog, table_id, start, end) -> pd.DataFrame:
df = pd.read_sql(
"SELECT partition_spec, location, row_count FROM partitions WHERE table_id = ?",
catalog, params=(table_id,),
)
df["date"] = df["partition_spec"].str.extract(r"order_date=(\d{4}-\d{2}-\d{2})")
return df[(df["date"] >= start) & (df["date"] <= end)]
pruned = partitions_for_range(catalog, 1, "2026-08-02", "2026-08-02")
print(pruned)
print("Files a query engine will actually open:", pruned["location"].tolist())
partition_spec location row_count date
1 order_date=2026-08-02 s3://lake/silver/orders/order_date=2026-08-02 980 2026-08-02
Files a query engine will actually open: ['s3://lake/silver/orders/order_date=2026-08-02']
A query engine reads catalog metadata to decide which partitions are
relevant before listing or opening any files — this is why a well-kept
catalog makes WHERE order_date = '2026-08-02' scan one partition instead
of the whole table.
Data discovery: searching the catalog by column, not just table name¶
def find_tables_with_column(catalog, col_name) -> pd.DataFrame:
return pd.read_sql(
"""
SELECT t.db_name, t.table_name, t.owner, c.col_type
FROM columns c JOIN tables t ON c.table_id = t.table_id
WHERE c.col_name = ?
""",
catalog, params=(col_name,),
)
print(find_tables_with_column(catalog, "customer"))
At scale (hundreds of tables across teams) this "which tables have a
customer_email column" query is exactly how catalogs support impact
analysis and PII discovery — you can't govern what you can't find.
Traps¶
- Letting the catalog drift from reality. If a pipeline changes a
table's schema or writes new partitions without updating the catalog
(
MSCK REPAIR TABLEin Hive, or a catalog API call), query engines either miss data or fail outright — schema evolution (Level 2, Module 04) and cataloging must be part of the same deploy step. - No ownership metadata. A catalog without an
ownercolumn becomes useless the moment something breaks and nobody knows who to page. - Treating the catalog as optional for a "just files" lake. Even a small lake benefits from a catalog the moment more than one person or tool needs to discover what exists.
Cheat sheet¶
| Question | Catalog query |
|---|---|
| What columns does this table have? | columns joined to tables |
| Which files does a date-filtered query need? | partitions filtered by partition spec |
| Who owns this table? | owner column on tables |
| Which tables contain a given column? | columns grouped by col_name |
How It Actually Works¶
A data catalog's core mechanism is maintaining a separate metadata store that maps logical names (database.table) to physical locations and schema, decoupled from the actual data files — every query against a lake table starts by consulting this store before touching storage at all.
Concretely (Hive Metastore, AWS Glue Catalog, or Iceberg/Delta's own catalog layer): the
catalog persists, per table, a schema definition, a partition list (each partition mapped to
its physical storage path), and table properties (format, location) in its own backing
database (often a relational DB like MySQL for Hive Metastore). When a query engine plans a
query against db.table, it issues a metadata lookup against this catalog — not a LIST
against the object store — to discover which partitions and files exist and what types their
columns have. This indirection is precisely what lets a table's underlying files move,
compact, or reorganize without breaking existing queries, as long as the catalog is updated
to point at the new locations: consumers never hardcode physical paths.
Metadata harvesting (auto-discovering schema from raw files) works by having a crawler process sample files (read a Parquet footer, infer JSON structure from a sample of records) and write the inferred schema into the catalog — this is inherently best-effort and can diverge from the true underlying data if new files with a different structure are written after the last crawl, which is the mechanical reason catalogs get stale and why schema drift detection is usually implemented as a diff between the crawler's freshly inferred schema and the catalog's currently registered one.
Lineage tracking layers on top of this same metadata infrastructure: it's built by having each pipeline job explicitly emit records ("job X read table A, wrote table B") to a lineage store at run time — lineage isn't derived automatically by inspecting SQL or code in most production systems, it depends on instrumentation (OpenLineage-style hooks) at each engine's execution layer emitting those read/write facts as jobs actually run.
Exercise¶
Add a table_stats table (table_id, last_updated_at, total_rows,
total_bytes) and a refresh_stats(catalog, table_id) function that
recomputes total_rows as the sum of partitions.row_count for that table
and writes/updates the row. Then write a stale_tables(catalog,
staleness_hours) query that returns every table whose last_updated_at is
older than the threshold — the same check a data-freshness monitor runs
before paging someone about a stale table.