Databricks logoDeep dive

Anatomy of an Import: How a Unity Catalog Estate Becomes a Governed Graph

Take a single row of a Unity Catalog extract:

Anatomy of an Import: How a Unity Catalog Estate Becomes a Governed Graph

Take a single row of a Unity Catalog extract:

{ "table_catalog": "main", "table_schema": "sales", "table_name": "orders",
  "table_type": "MANAGED", "table_comment": "Customer orders.",
  "column_name": "order_total", "ordinal_position": 4,
  "full_data_type": "DECIMAL(10,2)", "is_nullable": "NO", "comment": "Grand total." }

By the time a CoreModels import finishes, that row is part of a governed Type with vendor identity main.sales.orders, an Element order_total whose governed primitive is Double, a NotNull check, two descriptions, and a metadata record that still reads DECIMAL(10,2) — character for character. This article is the map of everything in between: what lands where in the graph, how native types are translated, what the connector's audit rules check, and the places where the mapping is deliberately, explicitly lossy.

The shape of the pipeline

The Databricks connector never writes to the graph itself. It parses the three artifacts — information_schema (required), keys, lineage — into a vendor-neutral estate snapshot: datasets with fields and normalized checks, plus lineage edges. The shared integration layer then writes in two passes. Schema shape — types, fields, references, controlled values — flows through the same hardened schema writer every CoreModels import uses. Estate facts that governed schema deliberately does not hold — native types, check details, materializations, lineage, import state — ride the graph's extensibility instead: a vendor metadata mixin, a dedicated lineage relation group, and a state node. The payoff: vendor quirks never leak into governed meaning, and governed meaning never depends on vendor quirks.

Identity and datasets

A dataset's vendor identity is its three-part name, catalog.schema.table, and the estate as a whole is registered under the catalog's name. That identity is written onto the Type as a queryable mapping — standard databricks, URI main.sales.orders — which is what later audits, status reads, and cross-vendor reconciliation resolve against. The Type's label stays the bare table name; the full name also rides along as the physical name, which DDL generation later prefers.

Unity Catalog's table_type maps to a materialization vocabulary:

table_typeMaterializationGoverned kind
MANAGED (and anything unrecognized)tableTable
EXTERNALexternal_tableTable
VIEWviewView
MATERIALIZED_VIEWmaterialized_viewView

Table comments become Type descriptions; column comments become Element descriptions. Rows are processed in ordinal_position order so element ordering mirrors the lakehouse, and a duplicated column name within one table keeps its first occurrence. Tables-versus-views totals are recorded as facts on the import state, so status calls can report the estate's composition without re-reading it.

Columns and the type mapping

Every column carries two type facts, kept deliberately separate. The native typefull_data_type exactly as Unity Catalog reported it — is preserved verbatim in vendor metadata. The governed primitive comes from a family-based mapping shared by our warehouse connectors, with an explicit approximation flag whenever precision, length, or structure is lost:

Unity Catalog typeGoverned primitiveApproximated?
INT, BIGINT, SMALLINT, TINYINTIntegerno
DECIMAL(p,0)Integeryes — the precision bound is dropped
DECIMAL(p,s), s > 0Doubleyes — exact decimal to binary double
FLOAT, DOUBLEDoubleno
BOOLEANBooleanno
DATE, TIMESTAMPDateTimeno
STRINGStringno
VARCHAR(n), CHAR(n)Stringyes — the declared length is dropped
ARRAY<…>, MAP<…>, STRUCT<…>, BINARY, anything elseStringyes — no structural home
missing typeStringyes — a declared guess

The two-track design is why nothing is lost in practice: the governed primitive is what the graph reasons in and what every other format export inherits, while the verbatim native type is what drift audits compare and what generation writes back out — a DECIMAL(10,2) column round-trips as DECIMAL(10,2), not as the Double it was governed as.

Checks, keys, and the composite-key rule

is_nullable = "NO" becomes a NotNull check — the only check the required artifact yields on its own. The optional keys artifact is where constraint semantics come from, and it is parsed with one rule we consider non-negotiable: primary-key rows are grouped by constraint name before any uniqueness is asserted. A single-column primary key marks its column NotNull and Unique. A composite key marks every member NotNull and adds a dataset-level marker naming the member columns — it never labels individual members as unique, because individually they are not. Modeling composite keys as per-column uniqueness is a quiet lie many schema tools tell; the audit would then miss real duplicate risks.

Foreign-key rows become governed references: the element points at the target Type (resolved by its catalog.schema.table identity, with the referenced column recorded), plus a relationship check for bookkeeping. These references are what generation later turns back into FOREIGN KEY … REFERENCES clauses, and what gives agents join paths to reason over.

Lineage

Each system.access.table_lineage row becomes one edge in the integration's Depends-On relation group, and direction matters: the edge runs from the produced table to the tables it reads — orders_summary depends on orders. Pairs are deduplicated, and an edge is flagged external when either end is not part of the current extract, so a table fed from another catalog shows up honestly as depending on something outside the governed estate rather than silently dropping the fact.

What rides the vendor metadata mixin

Beside every imported node sits one value of the Databricks Unity Catalog Metadata mixin — the estate-bookkeeping channel, with properties namespaced by the vendor key: the raw native type (databricksDataType), the materialization, the physical relation name, a JSON summary of the node's checks (databricksChecks), and the vendor-side description. Two properties do double duty downstream: generation reads NOT NULL from the checks JSON (governed schema intentionally has no schema-level nullable — that constraint lives at the estate boundary and is declared as lossiness there), and it reads the native type in preference to any primitive fallback.

Unlike governed nodes, mixin values are refreshed on re-import. That is the additive contract stated precisely: governed meaning is never mutated by an import, while estate bookkeeping always reflects the latest extract. A state node completes the picture, recording the last import's timestamps, versions, counts, facts, and a fingerprint: the first sixteen hex characters of a SHA-256 over the concatenated artifacts, the same value audit reports and history entries carry, so "did anything change?" is a string comparison. The parsed snapshot itself is also persisted (size-capped; more below) to power artifact-free re-audits.

The audit rules

Audits report in three sections. Coverage (dataset-unmapped, field-unmapped) and drift (dataset-removed, field-removed, field-type-drift, enum-constraint-removed, enum-narrowed, enum-widened, contract-drift) are shared across all connectors — a retyped column produces, for example:

{ "section": "Drift", "severity": "Error", "code": "field-type-drift",
  "subject": "main.sales.orders.order_total",
  "message": "Field type changed since the last import.",
  "detail": "governed: DECIMAL(10,2), estate: STRING" }

Read the detail carefully: type drift is judged on the native type strings — the value recorded in the metadata mixin at import versus the full_data_type in the fresh extract — compared case- and whitespace-insensitively, so decimal(10, 2) and DECIMAL(10,2) are the same type. That is stricter than comparing governed primitives, which would shrug at DECIMAL(10,2) widening to DECIMAL(18,4): both map to Double, but only one is the number your reports were reconciled against.

On top of these, the Databricks connector contributes two conformance rules of its own:

CodeSeverityFires when
table-no-commentInfoa table or view has no comment — undocumented assets resist governance and agent grounding
key-column-undeclaredWarninga table column named id or *_id has no declared PK/FK and no uniqueness or relationship check

The second rule is deliberately narrow: it skips views, and it goes quiet the moment you declare the informational constraint in Unity Catalog — which is exactly the behavior you want from a nudge.

Generation: the round trip

Generate emits one artifact, coremodels_delta_tables.sql. For the estate above:

CREATE TABLE IF NOT EXISTS main.sales.orders (
    order_id BIGINT NOT NULL COMMENT 'Key.',
    customer_id BIGINT NOT NULL,
    status STRING COMMENT 'Allowed values: draft, active, closed.',
    order_total DECIMAL(10,2) NOT NULL COMMENT 'Grand total.',
    CONSTRAINT pk_orders PRIMARY KEY (order_id),
    FOREIGN KEY (customer_id) REFERENCES main.sales.customers
) USING DELTA
COMMENT 'Customer orders.';

Everything traces back to the graph: column types are the recorded native types (primitive fallbacks only for governed-first columns that never came from an import), NOT NULL comes from the checks JSON, the primary key comes from the governed uniqueness check, the foreign key from the governed reference, and the allowed-values comment from a governed taxonomy. String literals are escaped backslash-aware, because Databricks SQL treats backslash as an escape inside single-quoted strings.

Where the mapping is honest about its limits

  • Views are not generated. They are derived objects; generation skips them with a declared lossiness record rather than emitting DDL that pretends to define them.
  • One informational PRIMARY KEY per table, no UNIQUE constraints. Unity Catalog has no enforced UNIQUE, so only the first governed key column becomes the PK constraint — informational, as UC defines it.
  • Taxonomies ride comments. Allowed values appear on column COMMENTs; there is no enforced CHECK constraint to compile them into.
  • Cross-schema foreign keys narrow. The documented keys query is catalog-scoped while the required extract is schema-scoped, so an FK can point at a table outside the snapshot. The column is then written as a plain value rather than a governed reference, with a SemanticNarrowing record naming the target — extract both schemas together if you want the reference governed.
  • Malformed optional artifacts degrade, not fail. A keys or lineage file that is not valid JSON is ignored with a lossiness record; only an unusable information_schema fails the import — including the honest edge cases "the extract contains no rows" and rows carrying no table_name at all.
  • The snapshot has a cap. Past roughly 1.5 MB encoded, import reports snapshotStored: false with a lossiness record; fresh-artifact audits still work, but artifact-free re-audit has nothing stored to run against.
  • There is no live connection. CoreModels never holds Databricks credentials; live sync is a declared-but-deferred capability, and every byte the connector sees came from an extract you ran yourself.

Every one of those limits is reported in-band — as lossiness on a successful call, never as a silent omission. That is the property we optimize this connector for: not that the mapping is perfect, but that it is precisely, inspectably imperfect.

The extraction queries and the API calls that exercise all of this are in the Databricks quickstart in the CoreModels documentation.