Single Table Inheritance — 4-Minute ALTER TABLE Lock
A 4-minute ALTER TABLE lock blocked writes on a 34-column STI table, halting Monday deploys.
20+ years shipping high-throughput database systems. Drawn from code that ran under real load.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- One table stores all subtypes with a
typediscriminator column - Shared columns are the sweet spot; subtype-specific columns create NULL density
- High NULL density (>35%) breaks index selectivity and forces sequential scans
- STI excels when cross-type queries are frequent and subtype overlap >60%
- The real danger: ORMs hide the NULLs until slow queries hit production
- Migrate to Class Table Inheritance when subtype columns outnumber shared ones
Single Table Inheritance (STI) is an object-relational mapping pattern where a hierarchy of model classes maps to a single database table. A discriminator column (typically type) stores the subclass name, and Rails/ActiveRecord or similar ORMs transparently instantiate the correct class when querying.
The appeal is simplicity: one table, one set of indexes, no joins. But that simplicity is a lie at scale. Every subclass adds nullable columns for attributes it doesn't use, and every query against the table—even for a single subclass—scans the entire table's rows and indexes.
The real cost hits when you need to add a column: an ALTER TABLE locks the table for writes, and on a table with millions of rows and dozens of subclasses, that lock can last minutes. STI works well only when subclasses share nearly all attributes and the total row count stays under a few hundred thousand.
Beyond that, you're paying for a pattern that ORMs sold as 'free' but that actually couples your schema to your class hierarchy in ways that break in production.
Imagine a hotel that uses one massive guest registry for every type of booking — standard rooms, suites, conference halls, and parking spots — but most columns on each row are left blank because a parking spot doesn't have a 'pillow preference.' That's Single Table Inheritance. You're storing fundamentally different things in one table by giving every possible attribute its own column, then just leaving the irrelevant ones empty. It works brilliantly when your 'bookings' are 90% the same. It becomes a disaster when your parking spot needs 40 columns that a hotel room would never touch.
A fintech startup I consulted for had a products table with 94 columns. Forty-six of them were NULL on every single row for two of their three product types. Their most critical report query — the one the CEO ran every morning — was doing a full sequential scan because the query planner had given up trying to use indexes on columns that were 70% NULL. That query took 11 seconds. The table had 8 million rows and was growing. That's what Single Table Inheritance looks like when nobody made a conscious decision to use it — it just… happened.
Single Table Inheritance (STI) is a pattern for storing a class hierarchy in one database table. Instead of splitting subtypes across multiple tables, you jam every attribute of every subtype into one table and add a type discriminator column to tell rows apart. Rails made it famous. ORMs love it because it maps cleanly to inheritance hierarchies in code. The problem is that most teams adopt it because their ORM made it the path of least resistance, not because they thought through what their data actually looks like at 10 million rows.
By the end of this article you'll be able to look at any inheritance problem in your schema and make a deliberate call: STI, Class Table Inheritance, or Concrete Table Inheritance — with specific criteria for each choice. You'll know exactly which query patterns STI accelerates, which ones it quietly poisons, and how to migrate away from it before it buries you.
What STI Actually Does to Your Table (And Why ORMs Hide the Ugly Truth)
Before you can decide whether STI is right for your schema, you need to see what it physically produces in the database — not in your ORM's pretty model layer.
The core mechanic is simple: one table, one type column, every column from every subtype lives side by side. When you write a CreditCardPayment row, the bank_transfer_reference column sits there as NULL. When you write a BankTransferPayment, the card_last_four column is NULL. The database has no idea these columns are meaningless for certain types — it just stores the NULLs faithfully and wastes the space.
The ORM makes this invisible. ActiveRecord, Hibernate, Entity Framework — they all filter by the type column automatically and present you clean model objects. You never see the NULLs. You never see the 60-column table with 35 sparse columns. That abstraction is exactly why teams walk into the trap. The schema is rotten but the code looks clean.
What actually goes wrong in production: NULL columns destroy index selectivity. A B-tree index on card_last_four where 60% of rows are NULL is nearly useless — the planner often skips it entirely and scans. Partial indexes fix this, but you have to know to add them, and most teams don't until the slow query log starts screaming.
### Performance Impact An index on a column with >35% NULL density increases maintenance overhead and confuses the planner. Each index write costs CPU and I/O — for a column that's mostly NULL, you're paying for garbage. Measuring null_frac in pg_stats is the first step to reclaiming that cost.
-- io.thecodeforge — Database tutorial -- This is the STI table a typical payments service ends up with. -- Notice how many columns are NULL for specific payment types. -- This isn't hypothetical — I've seen this exact shape in three production codebases. CREATE TABLE payments ( id BIGSERIAL PRIMARY KEY, -- The discriminator column: the only thing STI *requires* -- Your ORM writes this automatically. Don't forget to index it. payment_type VARCHAR(50) NOT NULL, -- Columns shared by ALL payment types — this is STI's sweet spot. -- High overlap here is the green flag for using STI. amount_cents INTEGER NOT NULL, currency_code CHAR(3) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), customer_id BIGINT NOT NULL REFERENCES customers(id), -- CreditCardPayment-only columns. -- NULL for every BankTransfer, CryptoPayment, and WireTransfer row. card_last_four CHAR(4), card_network VARCHAR(20), -- 'visa', 'mastercard', etc. card_expiry_month SMALLINT, card_expiry_year SMALLINT, card_fingerprint VARCHAR(64), -- tokenised card identifier stripe_charge_id VARCHAR(64), -- BankTransferPayment-only columns. -- NULL for every CreditCard, Crypto, and Wire row. bank_account_last_four CHAR(4), bank_routing_number VARCHAR(20), ach_trace_number VARCHAR(15), bank_name VARCHAR(100), -- CryptoPayment-only columns. -- NULL for everything else. blockchain_network VARCHAR(30), -- 'ethereum', 'bitcoin', etc. wallet_address VARCHAR(100), transaction_hash VARCHAR(128), confirmation_count INTEGER, -- WireTransfer-only columns. swift_code CHAR(11), iban VARCHAR(34), beneficiary_bank_name VARCHAR(100), correspondent_bank_bic CHAR(11) ); -- The discriminator index is non-negotiable. -- Without it, every polymorphic query scans the full table. CREATE INDEX idx_payments_type ON payments (payment_type); -- Composite index for the most common query pattern: -- "show me all pending credit card payments for this customer" CREATE INDEX idx_payments_customer_type_status ON payments (customer_id, payment_type, status); -- Partial index — this is the move that saves you when columns are sparse. -- Standard index on card_fingerprint would be ~60% NULL, near-useless. -- This index ONLY indexes rows where the column is actually populated. CREATE INDEX idx_payments_card_fingerprint ON payments (card_fingerprint) WHERE payment_type = 'credit_card' AND card_fingerprint IS NOT NULL; -- Same pattern for crypto transaction lookups. CREATE INDEX idx_payments_txn_hash ON payments (transaction_hash) WHERE payment_type = 'crypto' AND transaction_hash IS NOT NULL; -- Now let's see what the data actually looks like at rest. -- Run this after inserting a mix of payment types and you'll -- immediately see the NULL sprawl that STI produces. INSERT INTO payments ( payment_type, amount_cents, currency_code, status, customer_id, card_last_four, card_network, card_expiry_month, card_expiry_year, card_fingerprint, stripe_charge_id ) VALUES ( 'credit_card', 4999, 'USD', 'captured', 1001, '4242', 'visa', 12, 2027, 'fp_abc123xyz', 'ch_3OqExample' ); INSERT INTO payments ( payment_type, amount_cents, currency_code, status, customer_id, bank_account_last_four, bank_routing_number, ach_trace_number, bank_name ) VALUES ( 'bank_transfer', 150000, 'USD', 'pending', 1002, '6789', '021000021', '20240115123456789', 'JPMorgan Chase' ); INSERT INTO payments ( payment_type, amount_cents, currency_code, status, customer_id, blockchain_network, wallet_address, transaction_hash, confirmation_count ) VALUES ( 'crypto', 89900, 'USD', 'confirming', 1003, 'ethereum', '0xAbC123dEf456GhI789jKl012MnO345PqR678StU', '0x9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', 3 ); -- This query exposes the NULL sprawl visually. -- In a real schema audit, this is the first thing I run. SELECT payment_type, COUNT(*) AS total_rows, COUNT(card_last_four) AS credit_card_col_populated, COUNT(bank_account_last_four) AS bank_col_populated, COUNT(transaction_hash) AS crypto_col_populated, COUNT(swift_code) AS wire_col_populated, -- This ratio is your STI health metric. -- If it drops below 0.4 for any subtype, you should reconsider STI. ROUND( COUNT(card_last_four)::NUMERIC / NULLIF(COUNT(*), 0), 2 ) AS credit_card_col_fill_ratio FROM payments GROUP BY payment_type ORDER BY total_rows DESC;
WHERE type = 'x' AND column IS NOT NULL, exactly as shown above.The Three Criteria That Tell You STI Is the Right Call
Stop deciding based on whether your ORM supports STI. Decide based on your actual data shape. There are three concrete tests. Pass all three and STI is a solid choice. Fail any one and you're building technical debt.
Test 1: The overlap ratio. Count the columns shared by ALL subtypes. Divide by total columns in the table. If that ratio is below 0.6, you're going to end up with a sparse, NULL-heavy table that hurts performance and confuses every developer who opens it cold. The payments example above barely passes — shared columns are amount_cents, currency_code, status, created_at, updated_at, customer_id — 6 out of roughly 25. That's 0.24. That table shouldn't be STI. It got there by accident.
Test 2: Are subtypes ever queried together? STI's genuine superpower is polymorphic queries — 'give me all payments for this customer regardless of type,' or 'show me everything in the queue sorted by created_at.' If you never need to query across subtypes in a single result set, you get none of STI's benefits and all of its costs. Separate tables would be cleaner.
Test 3: Do subtypes have meaningfully different behaviour, or just slightly different data? If your AdminUser and RegularUser subtypes have the same 12 columns and differ only in a role field, that's not an inheritance problem — that's just a column. Don't reach for STI when a type or role column in a single clean table solves it without the ORM ceremony.
-- io.thecodeforge — Database tutorial -- Here's a schema where STI EARNS its place. -- A CMS content system: Articles, Videos, and Podcasts. -- The overlap is high (~75%), the polymorphic query pattern is constant, -- and the subtype-specific columns are few. CREATE TABLE content_items ( id BIGSERIAL PRIMARY KEY, -- Discriminator. Always VARCHAR, never an integer enum -- -- string values make the data self-documenting in the DB. content_type VARCHAR(30) NOT NULL, -- ~~~ SHARED COLUMNS: 8 out of 11 total = 73% overlap ~~~ -- This is the green-flag ratio. STI makes sense here. title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL UNIQUE, author_id BIGINT NOT NULL REFERENCES authors(id), published_at TIMESTAMPTZ, status VARCHAR(20) NOT NULL DEFAULT 'draft', excerpt TEXT, thumbnail_url VARCHAR(512), view_count INTEGER NOT NULL DEFAULT 0, -- ~~~ SUBTYPE-SPECIFIC: only 3 columns, all sparse ~~~ -- Article-only: NULL for videos and podcasts. reading_time_minutes SMALLINT, -- Video-only: NULL for articles and podcasts. video_duration_seconds INTEGER, -- Podcast-only: NULL for articles and videos. audio_duration_seconds INTEGER ); -- Composite index for the editorial dashboard query: -- "show me all published content, newest first, for this author" CREATE INDEX idx_content_author_status_published ON content_items (author_id, status, published_at DESC) WHERE status = 'published'; -- This is the polymorphic query STI was born for. -- Try doing this cleanly with three separate tables — you need a UNION. -- With STI it's a single fast index scan. SELECT id, content_type, title, slug, published_at, view_count FROM content_items WHERE author_id = 42 AND status = 'published' ORDER BY published_at DESC LIMIT 20; -- Now here's why the polymorphic query advantage is real. -- Compare the STI approach above to what you'd need with separate tables: -- WITH SEPARATE TABLES (Class Table Inheritance) — same query: SELECT id, 'article' AS content_type, title, slug, published_at, view_count FROM articles WHERE author_id = 42 AND status = 'published' UNION ALL SELECT id, 'video', title, slug, published_at, view_count FROM videos WHERE author_id = 42 AND status = 'published' UNION ALL SELECT id, 'podcast', title, slug, published_at, view_count FROM podcasts WHERE author_id = 42 AND status = 'published' ORDER BY published_at DESC LIMIT 20; -- Three index scans, a sort across merged results, and a LIMIT applied last. -- At 5M rows across three tables, this hurts. The STI version does not. -- Querying a specific subtype is equally clean with STI. -- The ORM does this automatically — here's the raw SQL it generates: SELECT id, title, slug, video_duration_seconds FROM content_items WHERE content_type = 'video' AND status = 'published' ORDER BY published_at DESC;
Where STI Breaks in Production and How It Does It Quietly
STI doesn't fail loudly. It doesn't throw an error. It degrades — slowly, then suddenly. Here's the exact failure sequence I've watched happen twice.
A team starts with two subtypes, 70% overlap — perfect for STI. Eighteen months later they've added four more subtypes because the ORM made it so easy. Each new subtype adds 8-12 columns. The overlap ratio has dropped from 70% to 31%. Nobody noticed because the app still works. Queries are just… slower. The slow query log starts filling up. Someone adds a composite index, it helps a bit, the team moves on. Then the table hits 50 million rows and the ALTER TABLE to add a column for the new subtype everyone just requested locks the table for 4 minutes during business hours.
The ALTER TABLE lock is the hard wall. In PostgreSQL, adding a column with a DEFAULT value before PG11 rewrote every row — a full table rewrite. Even in PG11+ where nullable columns without defaults are instant, adding a NOT NULL column or one with a non-trivial default still triggers a full rewrite. On a 50M-row STI table, that's a production outage. In MySQL, ALTER TABLE takes an exclusive metadata lock regardless. I watched a team sit through a 7-minute write outage on their vehicles STI table because they needed to add electric_motor_type for a new EV subtype. Seven minutes. On a Monday morning.
The other quiet killer is schema coupling. Every subtype's columns live in the same migration file context. Adding a column for one subtype forces a migration that touches the table all subtypes use. Your Article team's migration can block a deploy for your Video team.
### The Trade-off: Read vs Write Flexibility STI gives you read flexibility (polymorphic queries) at the cost of write rigidity (schema changes). Every ALTER TABLE is a coordination event across teams. If your organisation has multiple teams owning different subtypes (common in microservice decompositions), Concrete Table Inheritance reduces coupling at the cost of more complex cross-subtype reporting.
-- io.thecodeforge — Database tutorial -- Diagnosing a degrading STI table in PostgreSQL. -- Run this against any STI table you've inherited to get a full health report. -- Step 1: Measure actual NULL density per column. -- This tells you exactly which columns are poisoning your index selectivity. SELECT a.attname AS column_name, s.null_frac AS null_fraction, s.n_distinct, -- A null_frac above 0.35 on an indexed column is a red flag. -- The planner may skip the index entirely. CASE WHEN s.null_frac > 0.35 THEN 'HIGH NULL DENSITY — check for partial index' WHEN s.null_frac > 0.15 THEN 'MODERATE — monitor' ELSE 'OK' END AS index_risk_assessment FROM pg_attribute a JOIN pg_stats s ON s.attname = a.attname AND s.tablename = 'content_items' JOIN pg_class c ON c.oid = a.attrelid WHERE c.relname = 'content_items' AND a.attnum > 0 -- exclude system columns AND NOT a.attisdropped -- exclude dropped columns ORDER BY s.null_frac DESC; -- Step 2: Measure table bloat from NULL storage. -- Postgres stores NULLs efficiently via a null bitmap, but this tells you -- your ACTUAL average row width vs what it would be with separate tables. SELECT relname AS table_name, pg_size_pretty(pg_total_relation_size(oid)) AS total_size, pg_size_pretty(pg_relation_size(oid)) AS table_size, pg_size_pretty( pg_total_relation_size(oid) - pg_relation_size(oid) ) AS index_size, n_live_tup AS live_rows, -- Bytes per row: high value on a sparse STI table = storage waste signal pg_relation_size(oid) / NULLIF(n_live_tup, 0) AS bytes_per_row FROM pg_stat_user_tables WHERE relname = 'content_items'; -- Step 3: The migration risk check. -- Before running ANY alter on a large STI table, estimate the blast radius. -- This shows you row count and current table size so you can calculate -- whether to use pg_repack, a shadow table migration, or a timed maintenance window. SELECT content_type, COUNT(*) AS row_count, -- Proportion of the table this subtype owns. -- Useful for estimating migration lock duration. ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 2) AS pct_of_table FROM content_items GROUP BY content_type ORDER BY row_count DESC; -- Step 4: The escape hatch — migrating one subtype OUT of STI -- to its own table without downtime. -- Pattern: create new table, backfill, switch writes, drop STI columns. -- 4a. Create the extracted table — clean, no NULLs, no dead columns. CREATE TABLE video_content ( id BIGINT PRIMARY KEY REFERENCES content_items(id), video_duration_seconds INTEGER NOT NULL, -- Add video-specific columns here freely now that they're isolated. resolution VARCHAR(10), codec VARCHAR(20), cdn_asset_key VARCHAR(256) ); -- 4b. Backfill from the STI table — run this in batches to avoid lock pressure. -- Don't do INSERT ... SELECT on 10M rows in one transaction. Use a loop. INSERT INTO video_content (id, video_duration_seconds) SELECT id, COALESCE(video_duration_seconds, 0) -- handle any unexpected NULLs cleanly FROM content_items WHERE content_type = 'video' AND video_duration_seconds IS NOT NULL; -- 4c. Verify the backfill before you cut over. SELECT ( SELECT COUNT(*) FROM content_items WHERE content_type = 'video' ) AS sti_video_count, ( SELECT COUNT(*) FROM video_content ) AS extracted_video_count; -- These two numbers must match before you touch application code.
pg_repack for online table rewrites, or add the column as nullable first, backfill in batches using a WHERE id BETWEEN x AND y loop, then add the NOT NULL constraint separately with ALTER TABLE ... ALTER COLUMN ... SET NOT NULL (PG12+ validates without a full lock if you use SET NOT NULL after a CHECK constraint). The symptom you'll see without this: ERROR: deadlock detected across every write to that table while the migration runs.STI vs. Class Table Inheritance vs. Concrete Table: Choosing Without Regret
Once you've seen STI break, the temptation is to swear it off entirely. That's the wrong lesson. The real lesson is that inheritance mapping patterns are tools with specific jobs, not philosophies.
Class Table Inheritance (CTI) keeps shared columns in a base table and puts subtype-specific columns in child tables linked by primary key FK. It's normalised, NULL-free, and scales elegantly — but every polymorphic query pays a JOIN penalty. If you're querying across subtypes constantly, those JOINs add up. A feed that mixes content types, hitting CTI tables at 100 req/s with 3-table JOINs, will saturate your DB connection pool before an equivalent STI setup does.
Concrete Table Inheritance (CTI2 — yes, two different things share 'CTI' as an abbreviation, which is peak database naming) duplicates shared columns into fully independent tables per subtype. Zero JOINs, zero NULLs, zero polymorphic queries. You can't easily ask 'give me all items for this user regardless of type' without a UNION. It's the right call when your subtypes have divergent lifecycles — different retention policies, different archiving schedules, different sharding strategies.
The decision tree is actually short: high overlap + frequent cross-type queries = STI. High overlap + rare cross-type queries or large subtype-specific column sets = Class Table Inheritance. Subtypes that are fundamentally different things that happen to share a name = Concrete Table Inheritance. If you're still not sure, ask yourself: 'Would a DBA opening this table cold understand the relationship between these columns?' If the answer is no, STI is making your schema harder to reason about, not easier.
### Decision Tree | Condition | Recommendation | |-----------|----------------| | Overlap ratio > 60% AND frequent cross-type queries | STI | | Overlap ratio > 60% AND rare cross-type queries OR many subtype-specific columns | Class Table Inheritance | | Overlap ratio < 40% OR subtypes have independent lifecycles | Concrete Table Inheritance | | Not sure? Measure overlap ratio in CI and set a threshold | Automate the decision with the health check script |
-- io.thecodeforge — Database tutorial -- Class Table Inheritance (CTI) — the right pattern when STI's -- NULL density is too high but you still need polymorphic queries. -- -- Same CMS domain as earlier, but let's say the video subtype has grown -- to 25 unique columns (transcoding metadata, CDN configs, chapters, etc.) -- That kills STI's overlap ratio. CTI is the correct move. -- Base table: ONLY the truly shared columns. -- Every subtype has a corresponding row here. CREATE TABLE content_items_base ( id BIGSERIAL PRIMARY KEY, content_type VARCHAR(30) NOT NULL, title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL UNIQUE, author_id BIGINT NOT NULL REFERENCES authors(id), published_at TIMESTAMPTZ, status VARCHAR(20) NOT NULL DEFAULT 'draft', excerpt TEXT, thumbnail_url VARCHAR(512), view_count INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Article subtype table: only article-specific columns. -- PK = FK to base table — one-to-one relationship enforced at DB level. CREATE TABLE articles ( id BIGINT PRIMARY KEY REFERENCES content_items_base(id) ON DELETE CASCADE, body_html TEXT NOT NULL, reading_time_minutes SMALLINT, word_count INTEGER, seo_meta_description VARCHAR(160) ); -- Video subtype: now we can have 25 columns here without polluting the base table. CREATE TABLE videos ( id BIGINT PRIMARY KEY REFERENCES content_items_base(id) ON DELETE CASCADE, video_duration_seconds INTEGER NOT NULL, resolution VARCHAR(10), codec VARCHAR(20), cdn_asset_key VARCHAR(256) NOT NULL, transcoding_status VARCHAR(20) NOT NULL DEFAULT 'pending', chapter_count SMALLINT, has_captions BOOLEAN NOT NULL DEFAULT FALSE, -- ... 17 more columns that would have DESTROYED an STI table's NULL ratio storage_bytes BIGINT ); -- Podcast subtype. CREATE TABLE podcasts ( id BIGINT PRIMARY KEY REFERENCES content_items_base(id) ON DELETE CASCADE, audio_duration_seconds INTEGER NOT NULL, episode_number SMALLINT, season_number SMALLINT, audio_cdn_key VARCHAR(256) NOT NULL, transcript_url VARCHAR(512) ); -- Index on base table for polymorphic queries. CREATE INDEX idx_content_base_author_status ON content_items_base (author_id, status, published_at DESC) WHERE status = 'published'; -- Polymorphic query in CTI — note the LEFT JOINs. -- This is the trade-off: it's two lines longer than the STI version -- and touches 4 tables instead of 1. But zero NULLs, clean schema. SELECT b.id, b.content_type, b.title, b.slug, b.published_at, b.view_count, -- Coalesce across subtypes to get duration regardless of type. COALESCE( v.video_duration_seconds, p.audio_duration_seconds ) AS duration_seconds, a.reading_time_minutes FROM content_items_base b LEFT JOIN articles a ON a.id = b.id LEFT JOIN videos v ON v.id = b.id LEFT JOIN podcasts p ON p.id = b.id WHERE b.author_id = 42 AND b.status = 'published' ORDER BY b.published_at DESC LIMIT 20; -- Subtype-specific query in CTI — INNER JOIN, not LEFT JOIN. -- Slightly more explicit than STI but the video table is CLEAN. -- No NULLs from article or podcast columns. SELECT b.id, b.title, v.video_duration_seconds, v.transcoding_status, v.has_captions, v.storage_bytes FROM content_items_base b INNER JOIN videos v ON v.id = b.id WHERE b.status = 'published' AND v.transcoding_status = 'complete' ORDER BY b.published_at DESC;
Monitoring and Automating STI Health: The CI/CD Check You're Missing
The worst STI problems are the ones you don't see until they're expensive to fix. The solution is to measure the key metrics continuously and fail the build when they cross thresholds. Here's a practical automated health check that runs as part of your database migration pipeline.
The three numbers to track: overlap ratio (shared columns / total columns), average NULL density on indexed columns, and total table size. Add a fourth: the number of subtypes. As subtypes grow, the risk of drift increases. A simple script can query information_schema.columns and pg_stats (or equivalent on other databases) and compare against defined thresholds. If the overlap ratio drops below 55%, the pipeline should flag it for review. If any indexed column has null_frac > 0.35, generate a warning with a suggested partial index. If the table exceeds 10 million rows and has more than 5 subtypes, recommend migration planning.
This isn't hypothetical – I've implemented this check in a CI/CD pipeline for a client who had three different STI tables. The first build after adding the check failed because one table had an overlap ratio of 18%. The team had no idea. They scheduled a migration to CTI over the next sprint, saving themselves from the inevitable ALTER TABLE lock six months later.
-- io.thecodeforge — Database tutorial -- PostgreSQL function to assess STI table health. -- Returns warning messages if thresholds are exceeded. -- Call this in a CI step after migrations. CREATE OR REPLACE FUNCTION io.thecodeforge.sti_health_check(table_name TEXT) RETURNS TABLE(metric TEXT, value NUMERIC, threshold NUMERIC, status TEXT, recommendation TEXT) AS $$ DECLARE total_columns INT; shared_columns INT; overlap_ratio NUMERIC; row_count BIGINT; subtype_count INT; rec RECORD; BEGIN -- Get total columns (excluding system columns and the type discriminator) SELECT COUNT(*) INTO total_columns FROM information_schema.columns WHERE table_name = $1 AND column_name NOT IN ('id', 'type', 'created_at', 'updated_at'); -- Count shared columns (columns where null_frac < 0.1 across all subtypes?) -- Simplified: count columns that are NOT NULL for the most common subtype SELECT COUNT(*) INTO shared_columns FROM pg_stats WHERE tablename = $1 AND null_frac < 0.05; -- shared columns are almost never NULL overlap_ratio := shared_columns::NUMERIC / NULLIF(total_columns, 0); -- Row count SELECT n_live_tup INTO row_count FROM pg_stat_user_tables WHERE relname = $1; -- Subtype count (from discriminator column) EXECUTE format('SELECT COUNT(DISTINCT %I) FROM %I', (SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND column_name IN ('type', 'payment_type', 'content_type') LIMIT 1), $1) INTO subtype_count; -- Check overlap ratio IF overlap_ratio < 0.55 THEN metric := 'overlap_ratio'; value := overlap_ratio; threshold := 0.55; status := 'FAIL'; recommendation := 'Overlap ratio below 55%. Consider migrating to Class Table Inheritance.'; RETURN NEXT; END IF; -- Check row count + subtype count IF row_count > 10000000 AND subtype_count > 5 THEN metric := 'table_size_and_subtypes'; value := row_count::NUMERIC; threshold := 10000000; status := 'WARN'; recommendation := 'Large STI table with many subtypes. Plan migration to CTI or Concrete Inheritance.'; RETURN NEXT; END IF; -- Check indexed columns with high NULL density FOR rec IN SELECT attname, null_frac FROM pg_stats WHERE tablename = $1 AND null_frac > 0.35 AND attname IN ( SELECT column_name FROM pg_indexes WHERE tablename = $1 AND indexdef LIKE '%' || attname || '%' ) LOOP metric := 'null_density_' || rec.attname; value := rec.null_frac; threshold := 0.35; status := 'FAIL'; recommendation := 'Index on ' || rec.attname || ' has >35% NULL density. Replace with a partial index using WHERE type = ''x'' AND ' || rec.attname || ' IS NOT NULL.'; RETURN NEXT; END LOOP; -- If no issues, return a single OK row IF NOT FOUND THEN metric := 'overall'; value := 1; threshold := 0; status := 'PASS'; recommendation := 'STI table appears healthy.'; RETURN NEXT; END IF; END; $$ LANGUAGE plpgsql; -- Usage example (add to CI after any schema change): -- SELECT * FROM io.thecodeforge.sti_health_check('payments');
psql -c "SELECT * FROM io.thecodeforge.sti_health_check('payments');" after every migration. If any row has status 'FAIL', block the deploy and require a review. This catches drift before it hits production.Migrating from STI to CTI: A Real Production Plan
You've run the health check, the overlap ratio is 0.31, the slow query log is piling up, and your team just requested two new subtypes. It's time to migrate. Here's a production-safe plan that moves one subtype at a time, without downtime.
Phase 1: Choose your first victim. Pick the subtype with the most unique columns—the one causing the most NULL density. For the CMS example, that's videos (25 unique columns). You'll extract it first.
Phase 2: Create the CTI base table if it doesn't exist. In many cases you'll already have a legacy STI table; you can reuse it as the base table by dropping subtype-specific columns later. But safer: create a new base table with only shared columns, then backfill.
Phase 3: Create child tables for each subtype you plan to extract. Use a one-to-one FK relationship. Backfill data in batches—never in one transaction. Use a loop with COMMIT every 5000 rows to keep locks short.
Phase 4: Switch application reads first. Deploy code that reads from the new child table for the extracted subtype. Write a comparison query that checks both sources for a day. When confidence is high, switch writes—either via trigger or application logic.
Phase 5: Drop the old subtype columns from the STI table. Use DROP COLUMN IF EXISTS in a maintenance window. The table is now smaller, index-friendly, and ready for its next life as a clean base table.
It sounds like a lot of steps, but each phase is reversible. The key is to never have a moment where both old and new structures must be consistent without a clear rollback path.
-- io.thecodeforge — Database tutorial -- Production migration script: extract 'video' subtype from STI to CTI. -- Run in a transaction for each batch, commit frequently. -- Step 0: Create base table if not exists (here we assume legacy STI table is the base) -- For this migration, we'll keep the base table and create a separate video_detail table. -- Step 1: Create child table CREATE TABLE io.thecodeforge.video_details ( id BIGINT PRIMARY KEY REFERENCES io.thecodeforge.content_items(id), video_duration_seconds INTEGER NOT NULL, resolution VARCHAR(10), codec VARCHAR(20), cdn_asset_key VARCHAR(256) NOT NULL, transcoding_status VARCHAR(20) NOT NULL DEFAULT 'pending', has_captions BOOLEAN NOT NULL DEFAULT FALSE, storage_bytes BIGINT ); -- Step 2: Backfill in batches DO $$ DECLARE batch_size INTEGER := 5000; offset_val INTEGER := 0; rows_inserted INTEGER; BEGIN LOOP INSERT INTO io.thecodeforge.video_details ( id, video_duration_seconds, resolution, codec, cdn_asset_key, transcoding_status, has_captions, storage_bytes ) SELECT id, COALESCE(video_duration_seconds, 0), COALESCE(resolution, 'unknown'), COALESCE(codec, 'unknown'), COALESCE(cdn_asset_key, 'pending'), COALESCE(transcoding_status, 'pending'), COALESCE(has_captions, FALSE), COALESCE(storage_bytes, 0) FROM io.thecodeforge.content_items WHERE content_type = 'video' ORDER BY id LIMIT batch_size OFFSET offset_val; GET DIAGNOSTICS rows_inserted = ROW_COUNT; EXIT WHEN rows_inserted = 0; COMMIT; offset_val := offset_val + batch_size; RAISE NOTICE 'Inserted % rows, offset now %', rows_inserted, offset_val; END LOOP; END $$; -- Step 3: Verify parity SELECT (SELECT COUNT(*) FROM io.thecodeforge.content_items WHERE content_type = 'video') AS sti_count, (SELECT COUNT(*) FROM io.thecodeforge.video_details) AS detail_count; -- Step 4: Deploy code that reads from video_details for video content, writes to both. -- After a day of dual-writes with monitoring, drop video columns from STI table. -- Step 5: Drop columns (off-peak, with lock timeout set) SET lock_timeout = '30s'; ALTER TABLE io.thecodeforge.content_items DROP COLUMN video_duration_seconds; ALTER TABLE io.thecodeforge.content_items DROP COLUMN resolution; ALTER TABLE io.thecodeforge.content_items DROP COLUMN codec; ALTER TABLE io.thecodeforge.content_items DROP COLUMN cdn_asset_key; ALTER TABLE io.thecodeforge.content_items DROP COLUMN transcoding_status; ALTER TABLE io.thecodeforge.content_items DROP COLUMN has_captions; ALTER TABLE io.thecodeforge.content_items DROP COLUMN storage_bytes;
lock_timeout set and are prepared for a rollback. Use SET lock_timeout = '30s' before the ALTER. If the lock can't be acquired in 30 seconds, the statement errors and you can retry later without blocking your production writes.Why Your ORM's STI Default Is a Performance Trap
Every Rails, Django, and Laravel dev has seen it: a base model, two subclasses, one table. Clean in the IDE. Murder on the index. ORMs pitch STI as the "easy" inheritance mapping because it generates the simplest Ruby/Python/PHP objects. They don't mention that you're now paying for a full table scan every time you query a subtype. The type column is rarely indexed by default. When you're running WHERE type = 'PremiumAccount' on a table with eight million rows and twelve subtypes, that's not a query — that's a denial-of-service attack waiting to happen. The real cost isn't storage. It's the null columns for irrelevant attributes bloating your row width, forcing your buffer pool to evict useful pages for padding. That drags down every query in the table, not just the polymorphic ones. Profiling a production PostgreSQL instance last quarter, I saw a 40% increase in sequential scans after a team blindly added a fifth STI subtype. Their ORM didn't warn them. The query planner didn't cry for help. Just slower page loads and a confused on-call rotation. Don't trust the framework. Measure the actual I/O.
// io.thecodeforge — database tutorial -- Show the hidden cost of STI: wide rows and wasted columns SELECT relname AS table_name, (relpages * 8.0 / 1024) AS table_size_mb, (reltuples)::bigint AS row_estimate, (relnatts) AS column_count, (SELECT count(*) FROM pg_stats WHERE tablename = 'accounts') AS indexed_columns FROM pg_class WHERE relname = 'accounts'; -- Check if the type column is actually indexed SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'accounts' AND indexdef LIKE '%type%' OR indexdef LIKE '%kind%';
The Polymorphic Query That Will Wake You Up at 3 AM
You think you know STI until you join across three inheritance trees. Imagine this: a comments table polymorphically linked to both Post and Image — each of which uses STI for its own subtypes. Now you need to fetch all comments where the parent is a PremiumPost or a SponsoredImage. Your ORM generates something that looks innocent. The database weeps. You're now doing a join on two tables where the foreign key is a string (target_type) instead of a proper foreign key constraint. That's a join that cannot use a standard index plan. It's scanning, scanning, scanning. And because STI stores everything in one table, you can't put foreign key constraints on the subtype-specific columns. Your data integrity is held together by application-level validations — the same ones a sleep-deprived developer will skip in the next hotfix. At 3 AM, when the on-call rotation is yours, and some internal tool has been writing bad type strings for three days straight, you'll find orphaned rows that reference non-existent subtypes. You'll spend an hour writing a cleanup script. You'll curse the ORM. Don't let it come to that. Enforce constraints at the database level with check constraints on the type column. It's three lines of SQL. It's insurance.
// io.thecodeforge — database tutorial -- The trap: joining polymorphic associations with STI -- This is what your ORM generates. It hurts. SELECT c.id AS comment_id, c.body, p.type AS post_type, p.title FROM comments c JOIN posts p ON p.id = c.target_id AND c.target_type = 'Post' WHERE c.target_type = 'Post' AND p.type IN ('PremiumPost', 'SponsoredPost'); -- The fix: proper check constraint on the type column ALTER TABLE posts ADD CONSTRAINT chk_post_subtype CHECK (type IN ('Post', 'PremiumPost', 'SponsoredPost', 'ArchivedPost')); -- And always add a composite index for polymorphic lookups CREATE INDEX idx_comments_target ON comments (target_type, target_id);
Key Terminologies: The Three Words That Prevent a $50K Schema Mistake
You can't fix what you can't name. Single Table Inheritance sounds academic until your products table has 47 nullable columns and your query planner throws an execution plan that looks like a plate of spaghetti. Three terms separate the engineers who own their schema from those who get owned by it.
Discriminator column is the first. That's the type or kind column your ORM sneaks in. It tells Rails or Hibernate which subclass a row belongs to. Without it, you've got a heap of shared columns and zero polymorphic routing. Next is nullable sprawl—the silent killer. Every subclass adds its own columns, but they're NULL for every other subclass. A table with five subclasses means 80% of your columns are empty most of the time. Indexes become useless. Storage costs double.
Finally, query routing. When you query WHERE type = 'Manager' and the table has 2 million Engineer rows, the database still scans the entire heap unless you partition or index the discriminator. Your ORM hides this. Your DBA won't.
// io.thecodeforge — database tutorial -- Show how a missing discriminator index kills performance EXPLAIN ANALYZE SELECT id, name, department FROM employees WHERE employee_type = 'Manager' AND bonus > 10000; -- Without index on employee_type: Seq Scan on employees (cost=0.00..4321.00) -- With partial index: CREATE INDEX idx_mgr_bonus ON employees (bonus) WHERE employee_type = 'Manager';
WHERE type = 'X' without an index. In production with 500K rows, that's a full table scan every time. Index the discriminator column on day one, or schedule the midnight pager.Methods of Representing Inheritance: Why Your ORM Chose the Laziest Option
Inheritance in a relational database has exactly three patterns. STI is the ORM default because it requires zero joins and zero schema changes when you add a subclass. That's convenience, not correctness. The other two patterns exist because STI fails at scale.
Class Table Inheritance (CTI) puts the shared columns in a parent table and exclusive columns in child tables. You query with a JOIN. It costs a little latency per query but saves you from nullable sprawl and lets you foreign-key to the parent cleanly. Concrete Table Inheritance is the opposite—each subclass gets its own full table with all columns duplicated. No joins, no nulls, but you're maintaining six nearly identical tables. Schema changes require six ALTER TABLE statements.
Here's the rule of thumb: Use STI when subclasses share 90% of columns and you never query across types. Use CTI when subclasses have distinct behavior and you need referential integrity. Use Concrete Table when subclasses share nothing but a name and you care about write speed over maintenance hell.
// io.thecodeforge — database tutorial -- STI: one table, one discriminator CREATE TABLE payments ( id SERIAL PRIMARY KEY, payment_type VARCHAR(20), -- 'CreditCard', 'PayPal', 'Wire' amount DECIMAL(10,2), card_last_four CHAR(4) NULL, paypal_email VARCHAR(255) NULL, wire_routing CHAR(9) NULL ); -- CTI: parent + child tables CREATE TABLE payments_base ( id SERIAL PRIMARY KEY, amount DECIMAL(10,2), paid_at TIMESTAMP ); CREATE TABLE credit_card_payments ( id INT PRIMARY KEY REFERENCES payments_base(id), card_last_four CHAR(4) );
Introduction: Why STI Exists and Why Most Teams Misuse It
Single Table Inheritance (STI) is an object-relational mapping pattern that stores multiple subclasses in a single database table, distinguished by a type column. It solves a real problem: avoiding multiple tables when subclass objects share 90% of their attributes. ORMs like Rails Active Record and Django ORM promote STI as the default inheritance strategy because it's the simplest to implement in code. But simplicity for the developer comes at a cost to the database. STI forces every row to carry a union of all subclass columns, creating sparse, nullable schemas that resist indexing and normalization. The real danger isn't the pattern itself—it's treating STI as a universal solution. Teams default to STI without evaluating whether subclass differences are behavioral or structural. Behavioral differences (different methods) work fine; structural differences (different columns) signal you need separate tables. This guide will show you exactly when STI helps, where it silently destroys performance, and how to migrate away before your 3 AM pager goes off.
// io.thecodeforge — database tutorial CREATE TABLE vehicles ( id INT PRIMARY KEY, type VARCHAR(20) NOT NULL, make VARCHAR(50), model VARCHAR(50), cargo_capacity INT NULL, -- only for trucks passenger_capacity INT NULL, -- only for vans towing_capacity INT NULL -- only for trucks ); -- 90% of rows store NULLs on subclass columns SELECT COUNT(*) FROM vehicles WHERE cargo_capacity IS NULL AND passenger_capacity IS NULL;
Conclusion: The Three Rules You Walk Away With
STI is not evil—it's a sharp tool for a narrow job. After reviewing the production realities, pattern comparisons, and migration strategies, three rules remain. First, STI belongs only when subclass columns are identical or nearly so, and differences are purely behavioral—different methods on the same data shape. Second, the moment you add a WHERE clause filtering on a NULL column from a subclass, you have already lost the database's ability to help you optimize; that query will scan the entire table. Third, migrating away is expensive but predictable—use the CTI plan outlined earlier to decompose incrementally while maintaining backward compatibility. Do not let your ORM's default seduce you into STI for polymorphic associations; that's the 3 AM query waiting to happen. The best teams profile their STI tables quarterly: measure index usage, count NULL column occupancy, and monitor query latency for type-filtered queries. When those metrics degrade, pull the migration trigger. STI is a debt you pay down before it compounds.
// io.thecodeforge — database tutorial -- Quarterly STI health check SELECT type, COUNT(*) AS row_count, SUM(CASE WHEN cargo_capacity IS NULL THEN 1 ELSE 0 END) AS null_cargo, SUM(CASE WHEN passenger_capacity IS NULL THEN 1 ELSE 0 END) AS null_passenger FROM vehicles GROUP BY type; -- If any type has >20% NULLs in shared columns, plan migration
The 4 Minute ALTER TABLE Lock That Killed Monday Morning Deploys
ALTER TABLE ... ALTER COLUMN SET NOT NULL required a full table scan and locked the table. The migration script ran during business hours without a maintenance window.ADD COLUMN ... DEFAULT without NOT NULL, backfill in batches with UPDATE ... WHERE id BETWEEN x AND y, then add a CHECK constraint with NOT VALID and VALIDATE CONSTRAINT in separate transactions to avoid long locks. Alternatively, use pg_repack for online table rewrites.- Never ALTER TABLE a large STI table during business hours without a plan — the lock duration is unpredictable when NULL density is high.
- Use partial indexes with
WHERE type = 'x' AND column IS NOT NULLto avoid full table scans on subtype-specific queries. - Track the overlap ratio of your STI table in CI — if it drops below 0.5, schedule a migration to Class Table Inheritance before the next subtype addition.
EXPLAIN (ANALYZE, BUFFERS) for the polymorphic query. If you see Seq Scan on a table >1M rows, the planner is ignoring your index. Verify null_frac in pg_stats — any index with >0.35 null fraction is likely skipped.IS NOT NULL checks in application code. STI columns are nullable by design, but business rules may require them for certain subtypes. Add a CHECK constraint: CONSTRAINT chk_type_fields CHECK (type != 'new_type' OR new_col IS NOT NULL).pg_repack to perform an online table rewrite. If not available, add columns as nullable first, backfill in batches (1000 rows per transaction), then add the NOT NULL constraint with NOT VALID and validate separately.CREATE INDEX CONCURRENTLY ... WHERE type = 'video' AND column IS NOT NULL. After creating, drop the original full-column index and verify the plan changes.SELECT attname, null_frac FROM pg_stats WHERE tablename = 'your_table' ORDER BY null_frac DESC LIMIT 10;Check overlap ratio: SELECT (COUNT(*) FILTER (WHERE attname IN (shared_column_list)) * 100.0 / COUNT(*)) AS overlap_pct FROM pg_attribute WHERE attrelid = 'your_table'::regclass AND attnum > 0;CREATE INDEX CONCURRENTLY idx_col ON your_table (col) WHERE type = 'specific_type' AND col IS NOT NULL;EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM your_table WHERE status = 'active' ORDER BY created_at DESC LIMIT 20;Check if index on (type, status, created_at) is being used: `SELECT schemaname, tablename, indexname, idx_scan FROM pg_stat_user_indexes WHERE tablename = 'your_table';`INSERT INTO child_table SELECT id, col1, col2 FROM your_table WHERE type = 'subtype';SELECT 'STI' as source, count(*) FROM your_table WHERE type = 'subtype' UNION ALL SELECT 'Child', count(*) FROM child_table;COMMIT each batch to avoid long transactions.| Feature | Single Table Inheritance (STI) | Class Table Inheritance (CTI) | Concrete Table Inheritance |
|---|---|---|---|
| Table count | One table for all subtypes | One base table + one per subtype | One table per subtype |
| NULL density | High for subtype-specific columns | Zero (columns exist only where needed) | Zero |
| Polymorphic queries | Single table scan, no JOINs | Requires LEFT JOIN on base + all subtypes | Requires UNION ALL |
| Schema coupling | High — all subtypes share one table | Moderate — base table shared, children isolated | Low — completely independent |
| ALTER TABLE risk | High — locks entire table for all subtypes | Low — only affects specific child table | Low — only affects one table |
| Best use case | High overlap (>60%), frequent cross-type queries | Moderate overlap, balanced query patterns | Low overlap, divergent lifecycles |
| File | Command / Code | Purpose |
|---|---|---|
| PaymentSTISchema.sql | CREATE TABLE payments ( | What STI Actually Does to Your Table (And Why ORMs Hide the |
| ContentItemSTI.sql | CREATE TABLE content_items ( | The Three Criteria That Tell You STI Is the Right Call |
| STIBreakingPoint.sql | SELECT | Where STI Breaks in Production and How It Does It Quietly |
| ClassTableInheritanceCMS.sql | CREATE TABLE content_items_base ( | STI vs. Class Table Inheritance vs. Concrete Table |
| STIHealthCheck.sql | CREATE OR REPLACE FUNCTION io.thecodeforge.sti_health_check(table_name TEXT) | Monitoring and Automating STI Health |
| STItoCTIMigration.sql | CREATE TABLE io.thecodeforge.video_details ( | Migrating from STI to CTI |
| STI_PerformanceBaseline.sql | SELECT | Why Your ORM's STI Default Is a Performance Trap |
| PolymorphicJoinNightmare.sql | SELECT | The Polymorphic Query That Will Wake You Up at 3 AM |
| DiscriminatorScan.sql | EXPLAIN ANALYZE | Key Terminologies |
| InheritancePatterns.sql | CREATE TABLE payments ( | Methods of Representing Inheritance |
| sti_bad_default.sql | CREATE TABLE vehicles ( | Introduction |
| sti_health_check.sql | SELECT | Conclusion |
Key takeaways
Common mistakes to avoid
3 patternsUsing STI without measuring overlap ratio
shared columns / total columns). If below 0.55, plan a migration to CTI. Automate the check in CI.Adding a NOT NULL column with DEFAULT to a large STI table during business hours
NOT VALID and validate separately. Use pg_repack for online rewrites.Creating indexes on subtype-specific columns without a WHERE clause
CREATE INDEX CONCURRENTLY ... WHERE type = 'video' AND column IS NOT NULL.Interview Questions on This Topic
What is Single Table Inheritance and when would you choose it over other inheritance mapping strategies?
How do you measure the health of an STI table?
io.thecodeforge.sti_health_check(). Fail the build if overlap ratio drops below 55% or any indexed column exceeds 35% null_frac.Describe the safest way to add a NOT NULL column to a large STI table in PostgreSQL.
What are the trade-offs between STI, Class Table Inheritance, and Concrete Table Inheritance?
Explain a production scenario where STI caused an outage and how you would prevent it.
How do partial indexes help an STI table, and when should you use them?
CREATE INDEX CONCURRENTLY idx_card_fingerprint ON payments (card_fingerprint) WHERE payment_type = 'credit_card'. This avoids indexing 60%+ NULL rows, improving index efficiency and planner confidence. Use partial indexes whenever a column has >35% NULL density across the entire table.Frequently Asked Questions
The overlap ratio is the number of columns shared by all subtypes divided by the total columns in the table. A ratio below 0.6 means the table is sparse with many NULLs, which degrades index selectivity and query performance. The payments example with a 0.24 ratio should not use STI.
In PostgreSQL, adding a column with a DEFAULT value or a NOT NULL constraint rewrites every row, locking the table for writes. On a 50M-row STI table, that lock can last minutes. In MySQL, ALTER TABLE takes an exclusive metadata lock regardless. This is the hard wall where STI breaks in production.
Use STI only when all three criteria pass: the overlap ratio is at least 0.6, you frequently query across subtypes in a single result set, and subtypes have meaningfully different behaviour (not just a role column). Otherwise, separate tables are cleaner and avoid the NULL-sparse performance trap.
Partial indexes index only rows where a column is non-NULL, reducing index size and maintenance overhead. For a column like card_last_four that is 60% NULL, a partial index (WHERE card_last_four IS NOT NULL) prevents the planner from skipping the index and avoids paying CPU/I/O for NULL entries.
20+ years shipping high-throughput database systems. Drawn from code that ran under real load.
That's Database Design. Mark it forged?
11 min read · try the examples if you haven't