TL;DR: a grid_size INT column on three tables (rooms, players, card_templates) looked like a sane denormalization for a multiplayer bingo game. Two months later I deleted it on all three tables, because the value is tautologically encoded in another column already in those rows. Derived state at read time was simpler, smaller, and less buggy. Here's the trail.
The setup
I'm building BingWow, a free real-time multiplayer bingo platform. Each player has a board JSONB column that stores their cells: clue ids, positions, and image references. Boards are 3×3 (9 cells), 4×4 (16), or 5×5 (25). Mobile players are clamped to 3×3 regardless of the host's pick — it's an intentional product invariant.
When I first shipped flexible grid sizes in March 2026, I added:
ALTER TABLE rooms ADD COLUMN grid_size INT NOT NULL DEFAULT 5;
ALTER TABLE players ADD COLUMN grid_size SMALLINT; -- NULL = "use the room's"
ALTER TABLE card_templates ADD COLUMN grid_size INT
CHECK (grid_size IN (3, 4, 5));
It read cleanly. Server endpoints did SELECT grid_size FROM rooms WHERE id = $1. The tap_claim RPC that runs on every cell tap dereferenced COALESCE(player.grid_size, room.grid_size, 5). All the bingo-detection logic could read one short integer instead of computing one. Win, right?
The bug class I kept hitting
Within six weeks I'd shipped four bugs traceable to grid_size drift:
-
Mobile clamp regression — a refactor wrote
room.grid_size = 5but generated a 9-cell board for a mobile player. Bingo detection then ran a 5-in-a-row check against a 9-cell array and threw out-of-bounds on every tap. -
Resize-mid-game — when a player resized from Medium to Large mid-session, the new board got written but the
players.grid_sizecolumn was only updated in one of three callsites. The fourth callsite (the auto-save on the cell-image edit path) overwrote the board without touching the column. -
Fork inheritance — forks inherited the parent's
grid_sizebut not always the parent'sboard. On rare race conditions a 4×4 parent produced a 3×3 fork that still claimed to be 4×4. -
The "card is being set up" panel that fired when
grid_sizeandjsonb_array_length(board)disagreed by exactly the time it took the auto-save to flush.
Every bug had the same shape: two sources of truth, drifting at different cadences.
The realization
grid_size is not independent state. It IS the board.
jsonb_array_length(board) = 9 → 3×3
jsonb_array_length(board) = 16 → 4×4
jsonb_array_length(board) = 25 → 5×5
Every callsite that wrote grid_size also generated the board at the same size in the same transaction. The two values were tautologically equal at write time. The bugs were all "we forgot to keep them equal" bugs.
The migration
Three sequential migrations dropped the column from each table. The most surgical one is the player-level drop, which had to rewrite the tap_claim RPC to derive grid_size from the board:
-- Before: COALESCE(v_player.grid_size, v_room.grid_size, 5)
-- After: SQRT(jsonb_array_length(v_player.board))::INT
RETURN jsonb_build_object(
'success', true,
'action', v_action,
'grid_size', SQRT(jsonb_array_length(v_player.board))::INT,
-- ...
);
ALTER TABLE players DROP COLUMN grid_size;
SQRT on integers in Postgres returns a double precision; casting to INT is exact for perfect squares 9, 16, 25. The cost is negligible — jsonb_array_length on a hot row is O(1) once the JSONB is parsed.
I kept the wire shape of the API unchanged. The HTTP response still emits player.grid_size: 5. Clients didn't change. The only thing that changed was where that integer came from.
What I'd do differently
I'd skip the column from day one. The instinct to denormalize "just in case the read cost matters" is hard to resist when you're staring at a query plan that reads two columns instead of one. The actual cost of SQRT(jsonb_array_length(board)) on a FOR UPDATE-locked single row is irrelevant — the network round-trip dominates by three orders of magnitude.
The thing I should have done is the thing I always tell other engineers and never do for myself: start with the simplest schema that encodes the invariant, only add denormalized columns when you have a profiled query that needs them. The invariant "grid_size is determined by board length" is structural. Encoding it in a separate column made the schema able to express illegal states, and every bug class I hit was a different illegal state realizing itself.
What I'd add to the schema instead
If you're going to keep computed values, encode them as GENERATED ALWAYS AS ... STORED columns. Postgres maintains them automatically, you can index them, and they can never drift:
ALTER TABLE players
ADD COLUMN grid_size INT GENERATED ALWAYS AS
(SQRT(jsonb_array_length(board))::INT) STORED;
I didn't go that route here because the derivation is cheap enough that I don't index on it. But it's the right pattern when you need both the simplicity of derivation AND the speed of an indexed read.
Try It
The whole codebase is live and used by real classrooms, party hosts, and senior activity centers. Built with Next.js 16, Supabase, Ably for real-time, Tailwind v4.
- Create a multiplayer card: bingwow.com/create
- Browse 2,000+ cards: bingwow.com/cards
- For teachers: bingwow.com/for/teachers
- Free, no signup, no ads, no premium tier: bingwow.com
If you've got a schema-bloat story or a column you regret adding, drop it in the comments. I'd love to read your trail.
Top comments (0)