Schema Genie
Automatically infers optimal star and snowflake schemas from raw DataFrames and generates production-ready DDL for Snowflake, Redshift, BigQuery, and PostgreSQL in seconds.
Solo. I own everything in the repo: the six-stage inference pipeline (type detection, cardinality, relationship detection, fact-table selection, schema-type recommendation, DDL generation), the four warehouse DDL generators behind a common BaseDDLGenerator interface, the dataclass IR (ColumnDefinition / ForeignKey / TableDefinition / InferredSchema), the deploy and diagram side-paths, the 68-test suite, and the PEP 621 packaging with four optional connector extras. There is no other contributor and no reviewer.
// the problem
What was broken
Every time a team lands a new dataset in a warehouse, someone has to sit down and decide which columns are measures, which are dimensions, which table is the fact table, where the foreign keys are, and whether the dimensions should stay flat or be normalised. That work is repetitive and almost entirely mechanical, but it blocks everything downstream — nobody can write a query or train a model until the tables exist. The usual coping strategy is a data engineer hand-writing CREATE TABLE statements from a spreadsheet of column names, then discovering three weeks in that the dimension tables have no history columns and point-in-time joins are impossible.
// constraints
What made it hard
It had to work on data as it actually arrives — a pandas DataFrame or a CSV with no schema file, no dictionary, no annotations. Nothing but column names, dtypes and values to reason from, which rules out anything that needs metadata and forces heuristics. It had to emit DDL for four warehouses whose dialects disagree on surrogate keys, type names, constraint support and physical layout, without asking the caller to know any of that. And it had to be a library other people install, so the hard dependency list needed to stay small (pandas, numpy, sqlglot, pyyaml) with connectors pushed to optional extras — a user targeting Postgres should not be made to install the Snowflake connector.
// architecture
How it fits together
The centre of the design is a warehouse-neutral intermediate representation. Inference never produces SQL; it produces dataclasses — a TableDefinition holding ColumnDefinitions tagged with one of six *semantic* types (id, date, currency, measure, category, text) plus ForeignKey edges. Only the generator layer knows SQL exists. The alternative was to let each stage emit dialect-aware fragments and concatenate them, which would have been fewer lines but would have meant every new warehouse touched every inference module. With the IR, adding a target means one file: a TYPE_MAP dict and a _table_ddl method on a BaseDDLGenerator subclass. SchemaGenie.__init__ resolves the target through a GENERATORS registry and fails loudly on an unknown name rather than falling back to a default dialect.
The semantic-type layer is the other deliberate choice. It would have been simpler to map pandas dtypes straight to SQL types, but dtypes cannot tell you that revenue should be NUMBER(18,2) and not FLOAT, or that a three-value string column is a category rather than free text. So detection runs name regex → dtype → cardinality ratio, in that order, first match wins, and every downstream decision keys off the semantic type instead of the dtype. That single indirection is what lets the same inferred schema produce NUMBER(18,2) on Snowflake, DECIMAL(18,2) on Redshift and NUMERIC on BigQuery, and it is what makes the fact-table scorer possible at all — the score is a function of how many columns are measures, which is a semantic question.
Caller
pandas DataFrames or CSVs handed to SchemaGenie.infer / infer_multi
detect_all_types
name regex, then dtype, then cardinality ratio (default 0.05) -> six semantic types
detect_relationships
name-stem pre-filter, then |A n B| / min(|A|,|B|) >= 0.8 -> FK edges
select_fact_table
(measures/cols) + (id_cols * 0.2) + log10(rows)/10; highest score becomes the fact
recommend_schema_type
any dimension with >40% category columns -> "snowflake", else "star"
_build_table
ColumnDefinition / TableDefinition / ForeignKey — the warehouse-neutral IR
BaseDDLGenerator subclass
per-target TYPE_MAP plus dialect physical design -> DDL string
export_ddl / deploy_ddl
write a .sql file, or execute against a live connection
// challenges
Where I got stuck
Picking the fact table without a schema
Row count alone is wrong — a date dimension can be longer than a small fact table, and a wide lookup table can be huge. I settled on an additive score in score_table: measure density (measures/total_cols), an FK-candidate bonus (id_count * 0.2), and log10(rows)/10 so size influences the result without dominating it. The log damping is the part that matters; with raw row count in the sum, the largest table always won regardless of shape.
Relationship detection is quadratic twice over
Comparing every column of every table against every column of every other table is O(tables² × cols²), and each comparison builds two Python sets over the whole column. The fix was a cheap name-stem pre-filter in _columns_are_related so only plausibly-matching pairs get the set intersection. The prefilter itself has a latent bug I have not fixed: it uses str.rstrip("_id"), which strips a *character set*, not a suffix — "paid".rstrip("_id") returns "pa". It has not produced a false match on any test data yet, but it is wrong and should be a proper suffix strip.
The dialects refuse to stay behind one abstraction
BaseDDLGenerator gives each target a TYPE_MAP and a generate method, and that covers types cleanly. Physical design does not abstract at all: Redshift wants DISTKEY/SORTKEY appended after the closing paren, BigQuery wants PARTITION BY DATE(col) and rejects foreign-key constraints outright, Postgres wants separate CREATE INDEX statements after the table. I stopped trying to generalise it and let each _table_ddl be openly dialect-specific — the shared parts are the type map and the SCD audit columns, and that is where the abstraction ends. The visible cost is that BigQuery nullability is currently smuggled into OPTIONS(description='NOT NULL') instead of being a real constraint.
Dead branches that heuristic ordering creates
In detect_type, the boolean check sits after is_numeric_dtype, and pandas reports bool columns as numeric — so the bool branch never runs, and bool columns fall through to the cardinality test (which does classify them as category, so the output is right by accident). The same function computes null_ratio and never uses it. Ordered first-match-wins heuristics are readable but they make unreachable code easy to write and hard to notice; the tests all passed because they only ever asserted the final label.
Multi-statement DDL against four different drivers
deploy_ddl has to execute a single DDL string, but the drivers disagree — psycopg2 takes the whole blob, Snowflake and BigQuery need one statement per call. I split on ; in _split_statements, which is correct for generated DDL (no string literals, no procedural bodies) and would break immediately on hand-edited SQL containing a semicolon inside a quoted default. It is a documented-by-code assumption, not a general SQL splitter, and this whole module has no test coverage.
// new ground
What I hadn't used before
_valid_from / _valid_to / _is_current on every dimension table at DDL time inverts that: the history columns exist before the first row lands, so point-in-time joins are possible from day one instead of requiring a migration months later. What I understand now is that the expensive part of SCD2 is never the merge query, it is retrofitting the columns onto a table that already has data.DISTKEY, SORTKEY and PARTITION BY as things you add later when queries get slow. They are CREATE TABLE decisions — Redshift distribution in particular is painful to change after load. Building the generators forced me to learn what each warehouse actually keys off, and why BigQuery's partitioning story (prune by date column) is a completely different mechanism from Redshift's (co-locate rows by join key).pyproject.toml where the dependency graph is a real design decision rather than a formality — four [project.optional-dependencies] groups (snowflake, redshift, bigquery, diagram) with lazy imports inside deploy.py and diagram.py that raise an ImportError naming the exact pip install schema-genie[...] command. The lazy-import-plus-helpful-error pattern is now my default for any optional integration.export_diagram builds <TABLE> markup as node labels to render ER boxes with per-column rows and colour-coded headers. Not difficult, but I did not know Graphviz supported it at all, and it turned a would-be custom renderer into about forty lines.// takeaways
What I took away
- Introducing a semantic type layer between pandas dtypes and SQL types was the single decision that made four dialects cheap, because every dialect difference collapsed into one dict lookup.
- Additive heuristic scores need their terms damped to comparable ranges — the
log10(rows)/10term exists purely so table size cannot outvote table shape. - First-match-wins heuristic chains read well and hide unreachable branches; the bool branch in
detect_typewas dead for weeks and every test still passed because assertions checked the label, not the path. - Tests clustered where I was least confident — the four DDL generators carry roughly a third of the 68 tests — and the modules with zero dedicated tests (
deploy.py,diagram.py,relationships.py,cardinality.py,scd.py) are exactly the ones where I later found the loose assumptions. - A README that quantifies impact with industry-average rates rather than measured runs is a liability in any conversation with an engineer; estimates should be labelled as estimates in the artifact itself, not just in the caveat line.
// the journey
How it actually went
I started this because I had written the same CREATE TABLE block by hand too many times, and the third time I typed _valid_from TIMESTAMP I realised the whole thing was a function of the DataFrame I already had open. The first version was much smaller than what shipped — type detection and a single Snowflake generator, no relationships, no fact selection. It worked on one table and was useless on real projects, because a real project is five tables and the interesting question is which one is the fact.
The thing I got wrong early was believing the fact table was the biggest table. That is true often enough to be seductive and wrong often enough to be dangerous — a date dimension is the obvious counterexample and it broke the first version immediately. Rewriting select_fact_table around measure density, with row count damped through a log, is when the inference stopped feeling like a guess. The related mistake was letting relationship detection compare every column against every column; on anything wider than a toy dataset it crawled, and the name-stem prefilter is a patch on a design I would think about differently now.
The moment it clicked was writing the second generator. I had built Snowflake first with the SQL strings inline, and porting it to Redshift meant either duplicating the whole thing or extracting an IR. Extracting the IR took an afternoon and the third and fourth generators each took under an hour — which is the clearest evidence I have that the abstraction boundary was in the right place. It also told me exactly where the boundary is *not*: physical design (distribution, partitioning, indexes) does not generalise, and I stopped fighting it.
What I would do differently: I let the repo drift in ways that are small but embarrassing. sqlglot>=10.0 is a hard dependency that nothing in the package imports — it was in the plan for SQL validation and never got wired in, but it still forces itself on every install. __init__.py still reports __version__ = "1.0.0" while pyproject.toml is at 1.1.0. primary_key_strategy is accepted, documented in the docstring, and never read — the generators always emit a surrogate key. numpy is imported in type_detector.py and unused. None of these break anything, and all of them are the kind of thing a reviewer catches in ten seconds; shipping solo with no second reader is precisely how they survived to release.
The other thing I would change is the v1.1.0 commit. It rewrote the README around cost-savings tables — dollar figures per project, monthly BigQuery savings — built entirely from published industry rates and pricing pages, not from anything this code was measured doing. The caveat line is there, but the tables read as results. I would rather have spent that commit writing a benchmark that produces one honest number than writing six persuasive ones.
// outcome
What it changed
Shipped as an installable Python package. Two releases are built in dist/: schema_genie-1.0.0 (7 Mar 2026, the full package — 28 files, ~2,000 lines) and schema_genie-1.1.0 (8 Mar 2026, documentation plus version bump), each as a wheel and an sdist. What ships: a six-stage inference pipeline over pandas DataFrames, DDL generation for Snowflake, Redshift, BigQuery and PostgreSQL, SCD Type 2 audit columns on every generated dimension table, YAML-driven configuration via SchemaGenie.from_config, direct deployment to a live warehouse connection, and optional Graphviz ER-diagram export. 68 tests collected and passing under pytest, concentrated on type detection, fact selection and the four generators.
// overview
In more detail
schema-genie eliminates 80–90% of manual data-modelling work. Given one or more pandas DataFrames, its 6-stage inference pipeline classifies column semantic types, detects FK relationships, scores and selects fact tables, recommends star vs snowflake topology, detects SCD Type 2 candidates, and emits fully-annotated, warehouse-specific DDL — complete with surrogate keys, audit columns, distribution keys, indexes, and FK constraints. Supports live deployment, ER diagram export, and YAML configuration.
// features
What it does
- 6-stage inference pipeline: type detection through DDL generation
- Semantic column type classification (id, date, currency, measure, category, text)
- FK relationship detection via value-set intersection scoring
- Fact table selection with composite scoring formula
- Star vs snowflake schema recommendation
- SCD Type 2 candidate detection with audit columns
- DDL for Snowflake, Redshift, BigQuery, and PostgreSQL
- ER diagram export (graphviz PNG/SVG/PDF)
// stack