<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Pandera |</title><link>https://kristianeschenburg.netlify.app/tag/pandera/</link><atom:link href="https://kristianeschenburg.netlify.app/tag/pandera/index.xml" rel="self" type="application/rss+xml"/><description>Pandera</description><generator>Source Themes Academic (https://sourcethemes.com/academic/)</generator><language>en-us</language><lastBuildDate>Tue, 24 Jun 2025 09:00:00 -0700</lastBuildDate><image><url>https://kristianeschenburg.netlify.app/img/Bayes.jpg</url><title>Pandera</title><link>https://kristianeschenburg.netlify.app/tag/pandera/</link></image><item><title>Building a Schema Registry from Scratch for a Scientific Data Platform</title><link>https://kristianeschenburg.netlify.app/post/schema-registry/</link><pubDate>Tue, 24 Jun 2025 09:00:00 -0700</pubDate><guid>https://kristianeschenburg.netlify.app/post/schema-registry/</guid><description>&lt;p>When I joined Just-Evotec Biologics (first as a data scientist, now as a data platform engineer), I inherited a data ecosystem that probably looked pretty familiar to what lots of others have dealth with: a dozen scientific instruments each outputting data in proprietary formats, a LIMS system with its own schema, upstream and downstream experimental systems, Excel workbooks scientists had been maintaining for years but hidden away from any production storage system, and a swathe of applications that had been developed previously but had very little in the way of data governance. We didn&amp;rsquo;t have a central schema definitions, very little in the way of contracts between producers (e.g. instrumentation) and consumers (e.g. scientists, applications, clients, more instruments), and no way to ask or answer &amp;ldquo;what&amp;rsquo;s being pulled down here?&amp;rdquo;.&lt;/p>
&lt;p>I&amp;rsquo;ve been slowly building up our data platform, including implementing a formal schema registry. Here, I&amp;rsquo;ll cover some core design decisions I made and patterns that worked for me for the registry. The implementation is domain (and in our case, functional group and stakeholder) agnostic.&lt;/p>
&lt;hr>
&lt;h2 id="why-not-just-use-an-existing-registry-tool">Why not just use an existing registry tool?&lt;/h2>
&lt;p>Some alternative solutions to implementing / using a schema registry include Confluent&amp;rsquo;s Schema Registry, AWS Glue, Great Expectations, etc. Confluent&amp;rsquo;s Schema Registry is excellent for Kafka-based event streams, AWS Glue has a data catalog, and Great Expectations handles data quality well (just like Pandera). But none of those really addressed all or most of my needs, or fit in with my environment: a mix of validated DataFrames, LIMS API outputs, Parquet files in S3, and a team of scientists, some of whom wrote Python but weren&amp;rsquo;t data engineers. I wanted the following:&lt;/p>
&lt;ol>
&lt;li>Define schemas as Python classes with static type annotations&lt;/li>
&lt;li>Automatically serialize those classes to human-readable YAML&lt;/li>
&lt;li>Query schemas by metadata (tier, data lineage stage, source system)&lt;/li>
&lt;li>Reconstruct a live Pandera model from a stored YAML definition at runtime&lt;/li>
&lt;/ol>
&lt;p>We&amp;rsquo;re primarily working with data tables (as opposed to events), so I wanted a coupling between a type agnostic representation like YAML and Pandera. However, I&amp;rsquo;ve since extended a lot of this functionality for event-based schemas. At some point, I might want to revisit Glue again so I don&amp;rsquo;t reinvent the wheel.&lt;/p>
&lt;hr>
&lt;h2 id="core-architecture">Core architecture&lt;/h2>
&lt;p>The general schema development flow is: &lt;strong>write Python → export YAML → publish to S3 → consume via the registry client at runtime&lt;/strong>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">author a Pandera contract (contracts/domains/**/schema.py)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> → export-models-to-yaml # Python model to canonical YAML
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> → upload-yaml-to-s3 # publish, through the canonical-dtype gate
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> S3 (what consumers actually read)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> → REGISTRY.get_pandera_model(name) # at runtime, in a pipeline
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> → a live Pandera DataFrameModel&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The important property of this diagram is the direction of the arrow into S3. Python is how schemas are &lt;em>authored&lt;/em>, but S3 is what consumers &lt;em>read&lt;/em>. A pipeline never imports a contract class. It fetches YAML and builds a Pandera model from it at runtime, which means a schema change propagates by re-publishing to S3, with no rebuild or redeploy of any consumer. I initially didn&amp;rsquo;t build this in, and found myself needed to re-install the registry every time I authored a new contract. Not ideal.&lt;/p>
&lt;p>The repository is a monorepo of three independently versioned pieces:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>What it is&lt;/th>
&lt;th>Who uses it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>the service&lt;/td>
&lt;td>Contract definitions, templates, source-system specs, the FastAPI API, and the export/publish tooling&lt;/td>
&lt;td>Deployed as a Lambda. Not installed as a library.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>the client&lt;/td>
&lt;td>Fetch schemas from S3 and deserialize them to Pandera models, plus the canonical dtype vocabulary&lt;/td>
&lt;td>Pipelines, and anything validating data against a schema (e.g. dashboards)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>the data IO package&lt;/td>
&lt;td>Connectors for the LIMS and for the parquet/delta lakehouse&lt;/td>
&lt;td>Pipelines&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>I started with one package and split it once pipelines started depending on it. A pipeline that just wants to validate a DataFrame should not be installing FastAPI, &lt;code>jsonschema&lt;/code>, and the whole publish toolchain to do it. The client should be thin, and the dependency direction one-directional a.k.a. the service depends on the client.&lt;/p>
&lt;h3 id="repository-layout">Repository layout&lt;/h3>
&lt;p>The code tree looks like this. Names are genericized so as to hide our proprietary namespaces and functionality, but the shape is exactly what we run:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">schema-registry/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── registry/ # the service
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── contracts/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ ├── templates/ # JSON Schema per schema_type
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ ├── dataframe/v1_0/schema.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ ├── event/v1_0/schema.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ └── api/v1_0/schema.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ ├── domains/ # the contracts themselves
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ ├── lab/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ └── analytics/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ └── cell_count/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ ├── v1_0/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ │ ├── schema.py # authored
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ │ └── schema.yaml # generated, committed
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ └── v2_0/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ ├── schema.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ └── schema.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ ├── operations/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ └── enterprise/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ └── utilities/dataframe/export.py # Python model → YAML
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── integrations/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ └── source_specs/ # contract → physical source table
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ └── lab/analytics/cell_count/v1_0/spec.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── api/ # FastAPI app (deployed as a Lambda)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ ├── app.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ ├── auth.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ └── routers/{domains,schemas,specs}.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── publish/sync_schemas.py # validation gates + S3 upload
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── scripts/ # the two CLI entry points
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── packages/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── schema-registry-client/ # what pipelines + dashboards install
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ └── schema_registry_client/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── contracts/utilities/dataframe/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │ ├── dtypes.py # the canonical vocabulary
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │ ├── mixins.py # SchemaInfo, SchemaMeta, ContractMixin (e.g. constants + fields applied to all schemas)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │ ├── registration.py # the decorator + path checks
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │ └── base.py # base DataFrameModel configs
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ └── io/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── registry.py # the consumer-facing Registry
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── backends/s3.py # index-backed S3 reads
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ └── adapters/ # YAML → live Pandera model
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └── data-io/ # source-system and lakehouse connectors&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I wanted to be able to seperate the client from the registry and contracts, so I want to emphasize which side of the &lt;code>packages/&lt;/code> line each concern lives on. Everything a &lt;em>consumer&lt;/em> needs, the dtype vocabulary, the identity dataclasses, the deserializer, sits in the client. Everything about &lt;em>producing&lt;/em> the registry, discovery, export, validation gates, upload, and the API, sits in the service. The decorator lives in the client rather than the service, which looks odd at first, but contracts are authored against it and the deserializer needs the same &lt;code>SchemaInfo&lt;/code> shape when it rebuilds a model, so it belongs on the shared side.&lt;/p>
&lt;p>The generated &lt;code>schema.yaml&lt;/code> sitting next to the authored &lt;code>schema.py&lt;/code> and getting committed is intentional. A schema change shows up in code review as a readable diff against the previous commit of the actual YAML contract, not just as a Python class that someone would have to mentally compile.&lt;/p>
&lt;h3 id="adding-a-schema-end-to-end">Adding a schema, end to end&lt;/h3>
&lt;p>The whole process for a contributor is only five steps:&lt;/p>
&lt;ol>
&lt;li>Create &lt;code>contracts/domains/&amp;lt;domain&amp;gt;/&amp;lt;subdomains&amp;gt;/&amp;lt;entity&amp;gt;/v1_0/schema.py&lt;/code> and write the Pandera model, decorated with &lt;code>@register_dataframe_schema&lt;/code>.&lt;/li>
&lt;li>If it maps to a physical source table, add the matching spec under &lt;code>integrations/source_specs/&lt;/code>.&lt;/li>
&lt;li>Run the export command. It walks the tree, checks that the declared name and version match the directory path, and writes &lt;code>schema.yaml&lt;/code> next to each &lt;code>schema.py&lt;/code>.&lt;/li>
&lt;li>Commit both files. CI re-runs the export and fails the build if anything is out of date.&lt;/li>
&lt;li>Run the upload command. It validates every document against its template, runs the canonical dtype gate, uploads to S3, and rewrites the index.&lt;/li>
&lt;/ol>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">poetry run export-models-to-yaml &lt;span class="c1"># Python models → canonical schema.yaml&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">poetry run upload-yaml-to-s3 &lt;span class="c1"># validate, publish, reindex&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Consumers of the registry pick the changes up on their next run, without any intermediate rebuilds or redeploys. So the rest of my post is mostly about what happens inside steps 3 and 5.&lt;/p>
&lt;hr>
&lt;h2 id="schema-identity-the-domainsubdomainentity-pattern">Schema identity: the domain/subdomain/entity pattern&lt;/h2>
&lt;p>I was very opinionated and rigid about the &lt;em>structure&lt;/em> of the registry, and not just about how to define the contracts themselves. Every schema gets a unique dotted name composed of domain, zero or more subdomains, and an entity name:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl"> * lab.analytics.cell_count
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> * lab.upstream.bioreactor_run
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> * operations.equipment.instrument
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> * enterprise.finance.purchase_order&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This naming scheme is enforced structurally. Schemas live at file paths that mirror their names:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">registry/contracts/domains/lab/analytics/cell_count/v1_0/schema.py&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>@register_dataframe_schema&lt;/code> decorator captures the identity at class definition time:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@register_dataframe_schema&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">domain&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;lab&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">subdomains&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;analytics&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">entity&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;cell_count&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">version&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;1.0&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">description&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;LIMS cell count assay table.&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">meta&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">SchemaMeta&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tier&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;bronze&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">alignment&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;source&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">source_system&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;lims&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">LIMSCellCountSchema&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ContractMixin&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">LIMSBaseSchema&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">run_row_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">pa&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">coerce&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">sample_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">pa&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Field&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">vcd&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">float&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">pa&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">nullable&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">coerce&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">viability&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">float&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">pa&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">nullable&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">coerce&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">run_date&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Timestamp&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">pa&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">nullable&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">coerce&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The decorator is thin and only builds a frozen &lt;code>SchemaInfo&lt;/code>, derives the dotted name from its parts, and stamps both onto the class:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">_register&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">cls&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">info&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">SchemaInfo&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">domain&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">domain&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">subdomains&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">subdomains&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">entity&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">entity&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">version&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">version&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">owner&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">owner&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">description&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">description&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">cls&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">__schema_info__&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">info&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">cls&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">__schema_meta__&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">meta&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="n">SchemaMeta&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="bp">cls&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>SchemaInfo.__post_init__&lt;/code> does a quick n&amp;rsquo; dirty validation by rejecting any domain, subdomain, or entity containing &lt;code>.&lt;/code>, &lt;code>/&lt;/code>, or &lt;code>-&lt;/code>, since those would corrupt the dotted name or the path it maps to, and then joins the parts into &lt;code>name&lt;/code>. The more expensive checks, that the folder version matches the declared version (&lt;code>v1_0&lt;/code> → &lt;code>1.0&lt;/code>) and that the directory path matches the dotted name, run at &lt;strong>export time&lt;/strong>, not at import time:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="n">RegistryConfig&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">enforce_domain_name&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">confirm_schema_name_matches_module&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">cls&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="bp">cls&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="n">RegistryConfig&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">enforce_path_version&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">confirm_schema_version_matches_module_version&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">cls&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="bp">cls&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">version&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">version&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Enforcing contract path structure at import time makes the contract classes impossible to define anywhere else, which is a problem the first time a scientist wants to draft a schema in a notebook to see what it looks like. Doing it at export means the rule is enforced on everything that gets published, while drafting stays cheap. There&amp;rsquo;s a context manager for the notebook usecase too:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">relaxed_registry_checks&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nd">@register_dataframe_schema&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">...&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">class&lt;/span> &lt;span class="nc">DraftSchema&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ContractMixin&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">BaseDataFrameSchema&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">...&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>But there is a tradeoff, such a badly-placed schema fails later than it could. In practice &amp;ldquo;later&amp;rdquo; is the export step, which runs in CI on every PR, so nothing badly-placed reaches S3 regardless. Contracts inherit from a base model that sets the validation posture in one place:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">BaseDataFrameSchema&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pa&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">DataFrameModel&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">class&lt;/span> &lt;span class="nc">Config&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">strict&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;filter&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">coerce&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">True&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>strict='filter'&lt;/code> drops columns not in the schema rather than raising, which for source-system data is almost always what you want. The LIMS base adds &lt;code>drop_invalid_rows = True&lt;/code> and the row identity columns every LIMS table carries.&lt;/p>
&lt;hr>
&lt;h2 id="versioning-semantic-path-encoded-and-strict">Versioning: semantic, path-encoded, and strict&lt;/h2>
&lt;p>Schema versions follow semantic versioning (&lt;code>1.0&lt;/code>, &lt;code>2.1&lt;/code>, &lt;code>1.0.1&lt;/code>) and are encoded into the directory structure as Python-safe package names:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">v1_0 → 1.0
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">v2_1 → 2.1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">v1_0_1 → 1.0.1&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The conversion is bidirectional and validated in both directions. When I need to add a new version of a schema, I create a new directory alongside the old one. Both continue to exist and be served by the registry.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">cell_count/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> v1_0/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> schema.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> schema.yaml ← auto-generated
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> v2_0/ ← new version; v1_0 still works
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> schema.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> schema.yaml&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Version resolution supports three modes: a specific version (&lt;code>&amp;quot;1.0&amp;quot;&lt;/code>), &lt;code>&amp;quot;latest&amp;quot;&lt;/code> (resolved by semantic comparison at read time), and &lt;code>None&lt;/code> (all versions). This last mode is useful when you need to understand the full version history of a schema. Resolving &lt;code>latest&lt;/code> uses &lt;code>packaging.version.Version&lt;/code> for comparison rather than string sorting, so &lt;code>10.0&lt;/code> sorts above &lt;code>9.0&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="n">strver&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">vers&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">semver&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Version&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">strver&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># don&amp;#39;t allow pre-releases, or dev-releases&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">semver&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pre&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="n">semver&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">dev&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">parsed&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">semver&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">strver&lt;/span>&lt;span class="p">))&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>So I can publish &lt;code>2.0.0rc1&lt;/code>, point a pipeline at it explicitly to test, and know that nothing pinned to &lt;code>latest&lt;/code> will pick it up by accident.&lt;/p>
&lt;hr>
&lt;h2 id="export-pipeline-python--yaml">Export pipeline: python → YAML&lt;/h2>
&lt;p>Rather than maintaining YAML files by hand, the build step automatically discovers and converts decorated Pandera models. Discovery works by walking the file tree looking for &lt;code>schema.py&lt;/code> files, dynamically importing them as namespace packages (which avoid collisions between modules at the same relative path), and inspecting each imported module for classes that subclass both &lt;code>pa.DataFrameModel&lt;/code> and &lt;code>ContractMixin&lt;/code> and have a populated &lt;code>__schema_info__&lt;/code>. The code builds a unique dotted module name from the full path:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">_path_to_module_name&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">filepath&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Path&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">package_root&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Path&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">rel&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">filepath&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">resolve&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">relative_to&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">package_root&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">resolve&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">parts&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">rel&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">with_suffix&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">parts&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s1">&amp;#39;.&amp;#39;&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">parts&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># → &amp;#34;contracts.domains.lab.analytics.cell_count.v1_0.schema&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Each import gets a unique name in &lt;code>sys.modules&lt;/code> to prevent collisions (ideally we wouldn&amp;rsquo;t be naming schemas the same name, but this approach allows for it). Setting &lt;code>mod.__package__&lt;/code> to the parent of that dotted name keeps relative imports inside the schema file working.&lt;/p>
&lt;p>Pandera fields are serialized into a YAML-friendly dictionary, and checks that can be represented cleanly (e.g. comparisons and membership tests) are serialized. Anything else is skipped with a warning, since they&amp;rsquo;re harder to represent. Not ideal if you &lt;em>do&lt;/em> want some more complicated validation logic, but works for now &amp;ndash; though I think Great Expectations is probably better for these sorts of checks than Pandera is.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># pa.Check.greater_than(0) → {&amp;#34;gt&amp;#34;: 0}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># pa.Check.isin([&amp;#34;A&amp;#34;, &amp;#34;B&amp;#34;]) → {&amp;#34;isin&amp;#34;: [&amp;#34;A&amp;#34;, &amp;#34;B&amp;#34;]}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># lambda df: df[&amp;#34;x&amp;#34;] &amp;gt; df[&amp;#34;y&amp;#34;] → warning, skipped&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The serializer returns a &lt;em>list&lt;/em> per check rather than a single dict, because some Pandera checks are compound. &lt;code>in_range&lt;/code> has to expand into a &lt;code>ge&lt;/code> entry and an &lt;code>le&lt;/code> entry, since the YAML vocabulary has no range primitive. The resulting YAML is human-readable and versionable:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">schema&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">lab.analytics.cell_count&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">domain&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">lab&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">entity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cell_count&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">version&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1.0&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">owner&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">data-platform&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">schema_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dataframe&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">description&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LIMS cell count assay table.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">meta&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tier&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bronze&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">alignment&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">source&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">source_system&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LIMS&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">columns&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">run_row_id&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">dtype&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">str&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nullable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">coerce&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">viability&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">dtype&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">float64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nullable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">coerce&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">ge&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">le&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="canonical-dtype-vocabulary">Canonical dtype vocabulary&lt;/h2>
&lt;p>My initial version of a Python-to-YAML-to-Python round trip wrote out whatever dtype spelling the contract author (a.k.a. me) used. Pandera and pandas and &lt;code>typing&lt;/code> will happily provide &lt;code>float&lt;/code>, &lt;code>float64&lt;/code>, &lt;code>double&lt;/code>, &lt;code>np.float64&lt;/code>, &lt;code>typing.List[float]&lt;/code>, and &lt;code>Timestamp&lt;/code> for what are actually three types. I didn&amp;rsquo;t want consuming services to have to deal with this ambiguity. The registry now defines a closed dtype vocabulary:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">scalars: str, int32, int64, float32, float64, bool, datetime64[ns]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">lists: List[&amp;lt;scalar&amp;gt;] (element one of int/float/str/bool)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Author-side spellings are normalized to that vocabulary in exactly one function, &lt;code>canonicalize_dtype&lt;/code>, called at export time:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">_SCALAR_ALIASES&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Dict&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;int&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;int64&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;integer&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;int64&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;float&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;float64&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;double&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;float64&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;str&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;str&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;str&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;bool&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;bool&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;boolean&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;bool&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;datetime&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;datetime64[ns]&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;timestamp&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;datetime64[ns]&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;date&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;datetime64[ns]&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># ... plus the canonical spellings mapping to themselves&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="publishing-contracts">Publishing contracts&lt;/h2>
&lt;p>The dtype vocabulary is only a contract if something enforces it, so publishing to S3 runs every document through a hard gate first:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">assert_canonical_dtypes&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">source&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&amp;lt;doc&amp;gt;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">schema&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">doc&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="p">{})&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;schema&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">schema&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;schema_type&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s2">&amp;#34;dataframe&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">problems&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">col&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">doc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;columns&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="p">[]):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">dtype&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">col&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;dtype&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">dtype&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">try&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">canonical&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">canonicalize_dtype&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dtype&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">except&lt;/span> &lt;span class="n">UnknownDtypeError&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">e&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">problems&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;column &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">col&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;name&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">!r}&lt;/span>&lt;span class="s2">: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">e&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="n">is_canonical&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dtype&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">problems&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;column &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">col&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;name&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">!r}&lt;/span>&lt;span class="s2">: dtype &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">dtype&lt;/span>&lt;span class="si">!r}&lt;/span>&lt;span class="s2"> is not canonical &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;(expected &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">canonical&lt;/span>&lt;span class="si">!r}&lt;/span>&lt;span class="s2">), re-export this schema.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">problems&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="ne">ValueError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Refusing to publish &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">schema&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;name&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">!r}&lt;/span>&lt;span class="s2">:&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2"> - &amp;#34;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2"> - &amp;#34;&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">problems&lt;/span>&lt;span class="p">))&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The two rejection cases are doing different jobs. Rejecting an unknown dtype catches a type consumers can&amp;rsquo;t deserialize. Rejecting a dtype that is merely &lt;em>non-canonical&lt;/em> (one that &lt;code>canonicalize_dtype&lt;/code> could fix) catches something else entirely: it means this YAML was not produced by the current exporter. Someone hand-edited it, or it was generated before the normalization step existed. Silently canonicalizing it at publish time would hide that. Refusing forces a re-export, which keeps the YAML in S3 in sync with the Python that claims to define it. The check acts as sort of a staleness detector.&lt;/p>
&lt;hr>
&lt;h2 id="schema-templates-validating-the-schemas-themselves">Schema templates: validating the schemas themselves&lt;/h2>
&lt;p>Separately from dtypes, each schema document is validated against a JSON Schema template selected by its &lt;code>schema_type&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">contracts/templates/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> dataframe/v1_0/schema.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> event/v1_0/schema.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> api/v1_0/schema.yaml&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At publish time the templates are themselves checked as valid JSON Schemas with &lt;code>Draft202012Validator.check_schema&lt;/code>, compiled once, and then each contract is validated against the template matching its declared type. A schema with no &lt;code>schema_type&lt;/code>, or one naming a type with no template, is a hard failure, and wont upload.&lt;/p>
&lt;p>This is the layer that lets the registry hold more than DataFrames. &lt;code>dataframe&lt;/code> contracts carry &lt;code>columns&lt;/code>. &lt;code>api&lt;/code> contracts carry &lt;code>request&lt;/code>, &lt;code>response&lt;/code>, and &lt;code>callback&lt;/code> sections. &lt;code>event&lt;/code> contracts carry their own shape. They all live in the same tree, share the same identity and versioning rules, and are all published through the same gate, but each is structurally validated against its own template.&lt;/p>
&lt;hr>
&lt;h2 id="schema-metadata-queryable-semantic-tags">Schema metadata: queryable semantic tags&lt;/h2>
&lt;p>Every schema can carry structured metadata in a &lt;code>SchemaMeta&lt;/code> dataclass:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@dataclass&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">frozen&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">SchemaMeta&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tier&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Literal&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;bronze&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;silver&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;gold&amp;#34;&lt;/span>&lt;span class="p">]]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">alignment&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Literal&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;source&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;canonical&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;denormalized&amp;#34;&lt;/span>&lt;span class="p">]]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">contract_type&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Literal&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ingress&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;internal&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;egress&amp;#34;&lt;/span>&lt;span class="p">]]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">source_system&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Union&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">List&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">]]]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">stage&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">None&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>These fields encode the data&amp;rsquo;s position in a medallion-style architecture. Bronze/silver/gold indicate the refinement level. Alignment captures whether data is still in the shape of its source system, has been canonicalized, or has been denormalized for consumption. Contract type captures directionality in the platform: ingress for uploads and instrument files, internal for the middle of pipelines, egress for published dashboards and APIs. This metadata is queryable:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">registry&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Registry&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">s3_bucket&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;my-data-lakehouse&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Find all bronze schemas sourced from the LIMS&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">lims_bronze&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">registry&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">find_by_meta&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tier&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;bronze&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">source_system&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;lims&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Find all schemas used as egress contracts&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">egress_schemas&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">registry&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">find_by_meta&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">contract_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;egress&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Get all tiers currently in use&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">registry&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_tiers&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1"># → [&amp;#34;bronze&amp;#34;, &amp;#34;gold&amp;#34;, &amp;#34;silver&amp;#34;]&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This became invaluable for impact analysis. When a source system changed its output, I could immediately identify which schemas were &lt;code>alignment=&amp;quot;source&amp;quot;&lt;/code> and &lt;code>source_system=&amp;quot;lims&amp;quot;&lt;/code> and therefore potentially affected.&lt;/p>
&lt;p>The same identity travels with the data, not just the registry. &lt;code>ContractMixin&lt;/code> can flatten a contract into a string key/value header for stamping onto parquet metadata:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;contract.domain&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;lab&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;contract.entity&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;cell_count&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;contract.version&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;1.0&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>So a file on disk can answer which contract and which version produced it, without anyone having to consult a pipeline log.&lt;/p>
&lt;hr>
&lt;h2 id="runtime-registry">Runtime registry&lt;/h2>
&lt;p>The &lt;code>Registry&lt;/code> class wraps an S3 backend and provides the consumer-facing API. It&amp;rsquo;s thin by design. Most logic lives in the export pipeline and the contract definitions themselves.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">schema_registry_client&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">REGISTRY&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Load a schema as a live Pandera model&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">Model&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">REGISTRY&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_pandera_model&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;lab.analytics.cell_count&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">version&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;latest&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Validate a DataFrame against it&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">validated&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Model&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">validate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">df&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Or get the raw YAML doc&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">doc&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">REGISTRY&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_schema&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;lab.analytics.cell_count&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">version&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;1.0&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>get_pandera_model&lt;/code> builds a class dynamically: each YAML column becomes a Pandera &lt;code>Field&lt;/code> plus a type annotation, the check dicts become &lt;code>Field&lt;/code> kwargs, and the schema&amp;rsquo;s config overrides are applied on top of the base model&amp;rsquo;s config. Because the dtype strings are guaranteed canonical, the annotation lookup is a single flat dict with seven entries and no fallbacks.&lt;/p>
&lt;p>The backend reads an index CSV written at upload time rather than doing live &lt;code>list_objects&lt;/code> calls:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">contracts/index/schema_index.csv # name, key, version, date, schema_type
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">integrations/index/spec_index.csv&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Listing names, resolving versions, and enumerating domains are all filters over that dataframe. It loads once and stays cached on the backend instance. This keeps latency predictable and avoids a per-request S3 API call every time something enumerates the registry. The cost is that the index is a build artifact like everything else: it&amp;rsquo;s rewritten on every publish, so it&amp;rsquo;s accurate as of the last upload and no fresher.&lt;/p>
&lt;p>Alongside the contracts, the registry stores integration specs that map a contract to its physical source table: schema and table name, a &lt;code>field_to_source&lt;/code> column mapping, and the created/modified columns used for date partitioning. That&amp;rsquo;s what lets a pipeline say &amp;ldquo;load this contract for this date&amp;rdquo; without knowing anything about the source system&amp;rsquo;s table layout. It&amp;rsquo;s also the seam where a source system renaming a column becomes a spec change rather than a pipeline change.&lt;/p>
&lt;hr>
&lt;h2 id="api">API&lt;/h2>
&lt;p>The registry is also served over HTTP by a FastAPI app deployed as an AWS Lambda function, with Cognito authentication applied as a global dependency and a small set of public paths for health and docs.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>Path&lt;/th>
&lt;th>Description&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>GET&lt;/code>&lt;/td>
&lt;td>&lt;code>/domains/&lt;/code>&lt;/td>
&lt;td>List all domains&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>GET&lt;/code>&lt;/td>
&lt;td>&lt;code>/schemas/&lt;/code>&lt;/td>
&lt;td>List schema names (&lt;code>?domain=&lt;/code>, &lt;code>?version=&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>GET&lt;/code>&lt;/td>
&lt;td>&lt;code>/schemas/{name}&lt;/code>&lt;/td>
&lt;td>A schema document (&lt;code>?version=latest&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>GET&lt;/code>&lt;/td>
&lt;td>&lt;code>/schemas/{name}/versions&lt;/code>&lt;/td>
&lt;td>List versions for a schema&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>GET&lt;/code>&lt;/td>
&lt;td>&lt;code>/schemas/{name}/diff&lt;/code>&lt;/td>
&lt;td>Diff two versions (&lt;code>?from=&amp;amp;to=&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>POST&lt;/code>&lt;/td>
&lt;td>&lt;code>/schemas/{name}/validate&lt;/code>&lt;/td>
&lt;td>Validate a payload against a schema section&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;code>diff&lt;/code> endpoint answers &amp;ldquo;what actually changed between 1.0 and 2.0&amp;rdquo;, which is helpful if something starts breaking after publishing schemas. The &lt;code>validate&lt;/code> endpoint lets a service check a payload against an &lt;code>api&lt;/code>-type contract over HTTP, without installing the client or writing Python at all. It refuses if you point it at a &lt;code>dataframe&lt;/code> schema, since there&amp;rsquo;s no meaningful section to validate against.&lt;/p>
&lt;p>Mostly, though, the API exists so that the registry is browsable by people who aren&amp;rsquo;t going to &lt;code>pip install&lt;/code> anything. Being able to send a scientist a URL that shows exactly what a table contains has done more for adoption than any amount of documentation. I ended up building a nice dashboard that displayed datatable-specific contracts as tables, and let&amp;rsquo;s users browse by domain, subdomain(s), entity, and version. It&amp;rsquo;s much easier to look at than a YAML file or Pandera class.&lt;/p>
&lt;hr>
&lt;h2 id="closing-toughts">Closing toughts&lt;/h2>
&lt;p>A schema registry is about making implicit contracts explicit. Before building this, the &amp;ldquo;contract&amp;rdquo; between a pipeline and its consumer (e.g. dashboards, APIs) was whatever the pipeline happened to output on any given run. Afterwards, it was a versioned, machine-readable document that both sides could validate against independently. None of this is domain-specific, and these same patterns would work for any environment where you need versioned, queryable, code-first schema definitions that can be serialized and distributed. I&amp;rsquo;ve incorporated contracts for everything from scientific instruments, to finance, to ERP domains in our registry.&lt;/p>
&lt;p>I underestimated how much work the &lt;em>enforcement&lt;/em> would be, as opposed to the contract definitions themselves. Defining schemas as Python classes is easy, and scientists who are somewhat Python-saavy can do this easily as well. Making it impossible to publish a schema that consumers can&amp;rsquo;t read, or to end up with two spellings of the same type, or to quietly delete something a pipeline still depends on, took a lot longer, and is what makes the system robust. For our small team in a domain with complex, heterogeneous data, implementing this registry and schema enforcement has been a huge boon.&lt;/p>
&lt;hr>
&lt;p>&lt;em>I work on data platform infrastructure at a biologics company. The patterns in this post are generalized from production code, with domain-specific details abstracted away. The core framework concepts are shareable.&lt;/em>&lt;/p></description></item></channel></rss>