Back in 2012, a myth started circulating that Reddit only had two tables, a thing table and a data table. A thing represented an entity (a noun) such as a user, a comment, a post and so forth. And data stored all the attributes related to that thing. In reality, they had two tables for each entity: an entity_thing and an entity_data. The intriguing part to all this is that they used Postgres.
Now, this is obviously not the optimal design for your database. And RDBMSes have come a long way since then to make this technique frankly obsolete.
But, for the sake of knowledge (and, well, sheer curiosity), let's look at how we would implement this today.
Some helpful background
Steve Huffman, Reddit's co-founder, did a presentation in 2010 about the lessons learnt building Reddit. In it, he talked about an "open schema". The Reddit team spent a lot of time thinking about the database, and with each new feature, they realized doing schema updates became more excruciating as the platform grew. Their solution to the problem was essentially turning Postgres into a key-value store, eliminating a class of migration problems entirely.
If you're wondering why you might want to avoid schema changes in the mid 2000s when you're handling 270 million page views per month, read the next section. If you know why, you're welcome to skip.
A quick deep-dive
Postgres has a mechanism called locking that protects the schema of a table from changing while operations (like a read or write) are happening.
It uses table-level locks to manage concurrent access. Operations like SELECT acquire an AccessShareLock ("I'm reading the table — feel free to read or write alongside me"), while INSERT and UPDATE acquire a RowExclusiveLock ("I'm writing rows to the table — others can write too, but don't touch the schema"). Schema modifications (like ALTER TABLE), however, require an AccessExclusiveLock ("This entire table is mine and mine alone — everyone waits in line until I'm done").
Now, locks are managed by a lock queue, and this queue is the source of migration headaches for many.
Say you add a new column to a table and run a migration. Your migration appends an AccessExclusiveLock to the lock queue. As you can probably tell from the lock's name, access is exclusive to the migration. If a slow SELECT query was already running, your migration has to wait in line until the SELECT finishes. But if another query comes in, it sees the migration in line with an exclusive access lock. Because the migration's lock conflicts with its own, it is forced to queue up behind the migration.
Even though the migration is yet to run, it's blocking all incoming traffic with its lock. And it might take anywhere from a few seconds to a whole day to get unblocked. That means your system data becomes inaccessible. And that's a disaster if you have millions of users constantly commenting and posting, like Reddit.
To see how this "cursed" approach works in practice, let's write some Typescript, Drizzle v1 and Hono. Unlike Reddit, we'll actually only have two tables. I'll proceed assuming you have some background knowledge in the technologies I've chosen. If not, just ask Claude, GPT or Gemini to explain the code to you (or give you a quick crash course).
1. Schema definition
In Drizzle, I'll define two core tables:
thing- the generic entity shell with common metadatadata- the key-value store
// src/db/schema.ts
import * as p from 'drizzle-orm/pg-core'
// "Thing" table. Holds shared metadata.
export const thing = p.snakeCase.table('thing', {
id: p.uuid().defaultRandom().primaryKey(),
type: p.text().notNull(), // 'user', 'post', 'comment' etc. This should probably be an enum.
createdAt: p.timestamp().defaultNow().notNull(),
})
// "Data" table. Stores KV pairs of "thing" attributes.
export const data = p.snakeCase.table('data', {
thingId: p.uuid().references(() => thing.id, { onDelete: 'cascade' }).notNull(),
key: p.text().notNull(),
value: p.text().notNull(),
}, table => [ p.primaryKey({ columns: [table.thingId, table.key] })])
Notice that because value is stored as text, any booleans, timestamps, or integers get stringified on insert. My application layer has to handle parsing "true" back to true or "42" back to 42 on every read (oof!).
2. API layer
Because Postgres doesn't know the shape of my data, my API service layer has to convert the db records into typed domain objects that I can use and vice versa (break down objects into thing and data rows).
// src/utils.ts
import { eq } from 'drizzle-orm'
import { db } from '@/db'
import { thing as ThingTable, data as DataTable } from '@/db/schema'
export const createThing = async (type: string, attributes: Record<string, string>) => {
return await db.transaction(async tx => {
// 1. Insert thing.
const [thing] = await tx
.insert(ThingTable)
.values({ type })
.returning()
// 2. Add each kv pair as row in data.
const dataRows = Object.entries(attributes).map(([key, value]) => ({
thingId: thing.id,
key,
value,
}))
if (dataRows.length > 0) await tx.insert(DataTable).values(dataRows)
return thing.id
})
}
export const getThing = async (id: string) => {
// 1. Try getting the thing.
const [thing] = await db
.select()
.from(ThingTable)
.where(eq(ThingTable.id, id))
if (!thing) return null
// 2. Get data key-value rows
const dataRows = await db
.select()
.from(DataTable)
.where(eq(DataTable.thingId, id))
// 3. Construct JS data object
const data = dataRows.reduce((obj, row) => {
obj[row.key] = row.value
return obj
}, {} as Record<string, string>)
// 4. Return object containing both thing and data properties.
return {
...thing, // id, type, createdAt (common)
...data, // i.e. firstname, lastname, email etc. (thing-specific)
}
}
In my app.ts, I'll have this:
// src/app.ts
import { Hono } from 'hono'
import { createThing, getThing } from '@/utils'
const app = new Hono()
app.post('/users', async (c) => {
const body = await c.req.json()
const userId = await createThing('user', {
firstname: body.firstname,
lastname: body.lastname,
email: body.email,
})
return c.json({ success: true, userId }, 201)
})
app.get('/users/:id', async (c) => {
const userId = c.req.param('id')
const user = await getThing(userId)
if (!user) return c.json({ error: 'User not found' }, 404)
return c.json({ user }, 200)
})
And there we go! An app with two tables for all your needs. But wait, there's a catch, or rather, catches.
Why you should not do this
The first reason is obvious: this is not how relational databases are designed to work. Adopting this Entity-Attribute-Value pattern today gives you the performance and storage overhead of a relational db without its speed, safety and query powers. Let's dive into a couple other reasons:
Zero data integrity
Postgres can't enforce data types, NOT NULL requirements or foreign key relationships
- You may end up with an unintended string value for a
datekey or write typos likeemialinstead ofemailwhen adding attributes. - Imagine wanting to delete a user and all their related data. That would require a lot of boilerplate error-prone code to cascade-delete.
Querying is a nightmare
What if you wanted to get a user's posts, comments and other related data? You'd probably get the user first, then get posts where author_id is the user's id, then do something similar for comments, and so forth, like Reddit did (and you'd need to cache aggressively too). Or you could go down the treacherous path of self joins on data.
What to do instead: use jsonb or purpose-built NoSQL
If you want flexibility inside your schema, use JSONB columns instead. You can store fixed columns alongside a JSONB payload without destroying relational features. If this is not enough and you need a pure schema-less or document-based architecture, then move to purpose-built tools like Mongo or DynamoDB. Those will be way better than our custom brittle db orchestration nightmare on the API layer.
Conclusion
Having only two tables is possible, but it's not worth it. It's cool for toy projects but not a viable design for production.