You’ve just added allow_gifs to your user permissions table. That’s column number 47. Your schema now looks like a boolean yard sale, allow_video, allow_audio, allow_screen_share, allow_screen_share_with_audio, allow_screen_share_without_audio_but_with_a_curious_cat_filter. At what point does this become a problem? Is 50 the magic number where your database catches fire and your ORM files a formal complaint?
A developer on r/Database recently hit this wall. Their configuration table was accumulating flags like a digital hoarder, and they faced the existential question: keep adding columns, or burn it all down and switch to arrays, JSONB, or join tables? The comments ranged from “just use a bitmask and stop whining” to “I’ve run 100 boolean columns in production without issues.” Welcome to the gray area where database theory meets the stubborn reality of shipping code.
The 50-Flag Fallacy: It’s Not About the Number
Here’s the uncomfortable truth: the problem isn’t the count, it’s the meaning. Fifty boolean columns that represent independent, stable, well-defined permissions might be perfectly fine. Fifty boolean columns that are actually 12 different types of configuration mashed together? That’s a schema screaming for therapy.
The real issue is primitive obsession, using the simplest possible data structure (a boolean) to model something that’s evolving into a complex domain. Each new flag feels cheap at the moment. ALTER TABLE ADD COLUMN is a one-liner. But you’re not just adding a column, you’re adding to a growing maintenance tax:
- Query complexity:
WHERE allow_video AND allow_audio AND NOT allow_external_sharing AND (allow_recording OR allow_live_streaming)... - Application logic: Your code becomes a nested if-statement hellscape
- Documentation debt: Each flag needs explanation, default values, and migration notes
- Testing surface: 2^n possible combinations, and yes, someone will hit the weird edge case
As one normalization guide points out, this is how you end up with tables that try to carry everything, customer profiles, repeated phone numbers, order status history, product metadata, and support notes in one place. Six months later, you’re fighting duplicate records and reports that never quite match. The boolean explosion is just the canary in the coal mine.
The Bitmask Temptation: Compact, Opaque, and Deceptively Clever
The most upvoted suggestion on Reddit? Use a bitmask. Instead of 50 columns, you get one integer column where bit 1 = text, bit 2 = images, bit 4 = video, and so on. The storage is smaller, comparisons are lightning-fast, and you can check permissions with a single bitwise operation.
-- Setting permissions
UPDATE user_config SET permissions = permissions | 4, -- Enable video (bit 3)
-- Checking permissions
SELECT * FROM user_config WHERE (permissions & 4) = 4, -- Has video permission
But before you reach for this shiny hammer, consider the trade-offs:
- No NULL semantics: You can’t distinguish “unknown” from “false” without extra hackery
- Indexing pain: Bitmap indexes exist, but they’re not magic bullets for arbitrary bit patterns
- Readability disaster:
permissions = 37tells you nothing without documentation - Application coupling: Every app needs the same bit-to-meaning mapping compiled in
The Reddit thread devolved into a semantic argument about whether “bitmap” or “bitmask” was correct, which tells you everything about how fun this is to debug at 3 AM. One commenter suggested storing the mapping in JSON, which another promptly roasted: “It gives you the perfect opportunity to have mismatching JSON blobs in your DB for each row of data, and forces you to put all of the logic for managing broken JSON inside the application code!”
The JSONB Escape Hatch: Flexibility at What Cost?
Speaking of JSON, PostgreSQL’s JSONB is the modern developer’s favorite escape hatch. Just shove all those flags into a single column and call it a day:
ALTER TABLE user_config ADD COLUMN permissions JSONB DEFAULT '{}';
UPDATE user_config SET permissions = '{"video": true, "audio": false, "cat_filter": true}';
This solves the schema migration problem, you can add new flags without ALTER TABLE. But you’ve traded one problem for another:
- No referential integrity: The database can’t enforce that
allow_videois actually a boolean - Query performance: Indexing JSONB paths works, but it’s not as fast as native columns
- Type safety: Your application must handle missing keys, wrong types, and schema drift
- Analytics nightmare: Try writing a BI query that aggregates across 50 nested JSON keys
The normalization guide makes this clear: storing tags in JSON is acceptable in a raw event table, but dangerous in a mutable transactional table where tags drive constraints, billing, or permissions. You’re moving validation from the database (where it belongs) into application code (where it will diverge across services).
The Properly Normalized Approach: Join Tables and Domain Models
If you’re serious about modeling permissions as a domain, the correct solution is normalization. Create a permission_types table and a user_permissions join table:
CREATE TABLE permission_types (
permission_id INT PRIMARY KEY,
permission_name VARCHAR(50) UNIQUE NOT NULL,
description TEXT
);
CREATE TABLE user_permissions (
user_id INT REFERENCES users(id),
permission_id INT REFERENCES permission_types(permission_id),
granted_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (user_id, permission_id)
);
This is textbook 3NF. Each fact is stored once. You can add new permissions without touching the users table. You get true/false/null semantics naturally. You can attach metadata (who granted it, when, why) without contorting your schema.
The trade-off? Joins. Instead of SELECT * FROM users WHERE allow_video, you write:
SELECT u.* FROM users u
JOIN user_permissions up ON u.id = up.user_id
JOIN permission_types pt ON up.permission_id = pt.permission_id
WHERE pt.permission_name = 'allow_video';
For read-heavy workloads, this can hurt. The solution isn’t to denormalize prematurely, it’s to materialize views or cache projections for performance-critical paths. Normalize writes, denormalize reads deliberately.
The Middle Ground: Contextual Grouping
One Reddit commenter suggested grouping flags by context: allowed_uploads, allowed_permissions, allowed_chat_formats. This is a pragmatic compromise. Instead of 50 top-level booleans, you might have 5 columns of type TEXT[] or JSONB, each representing a coherent domain.
-- Using arrays
ALTER TABLE user_config ADD COLUMN allowed_uploads TEXT[] DEFAULT '{}';
UPDATE user_config SET allowed_uploads = ARRAY['image', 'video', 'audio'];
-- Querying arrays
SELECT * FROM user_config WHERE 'video' = ANY(allowed_uploads);
This preserves some database enforceability while reducing column sprawl. It’s a good fit when permissions cluster naturally. The key is cohesion, if you find yourself splitting logic across groups, you’ve just moved the problem.
When to Refactor: A Decision Framework
Don’t refactor just because you hit 50 columns. Refactor when you hit these triggers:
- New flags require changes in multiple tables: Your boolean is no longer a simple attribute
- You’re writing generic flag-checking functions:
hasPermission(user_id, 'some_string')suggests a domain model - Analytics queries are becoming unmaintainable: 50-column
SELECTlists andWHEREclauses - Different services interpret flags differently: The meaning of
allow_videois ambiguous across contexts - You’re hitting column limits: PostgreSQL’s 1,600 column limit seems generous until you hit it
The Reddit thread shows this split perfectly. One developer with 32 flags is comfortable going to 100. Another suggests separate config tables with key-value pairs plus caching. Both can be right, it depends on whether those flags represent independent toggles or a unified permission domain.
The Migration Playbook: From Booleans to Normalized Tables
If you decide to migrate, don’t do it in one terrifying ALTER TABLE statement. Use a phased approach:
Phase 1: Dual-write
-- Add new normalized structure
CREATE TABLE user_permissions_new (...);
-- Write to both old and new in application code
-- Read from old (source of truth)
Phase 2: Backfill
INSERT INTO user_permissions_new (user_id, permission_id)
SELECT user_id, permission_id
FROM user_config uc
CROSS JOIN permission_types pt
WHERE CASE
WHEN pt.permission_name = 'allow_video' AND uc.allow_video THEN 1
WHEN pt.permission_name = 'allow_audio' AND uc.allow_audio THEN 1
-- ... 48 more conditions
END = 1;
Phase 3: Reconciliation
-- Check for parity issues
SELECT user_id, permission_name
FROM user_permissions_new
EXCEPT
SELECT user_id, permission_name
FROM user_permissions_old;
Phase 4: Switch reads
Update application code to read from new tables, keeping old columns as fallback.
Phase 5: Deprecate
After a few release cycles, drop the old boolean columns.
This is the same migration playbook used when normalizing denormalized tables, as described in the normalization guide. The key is incremental change with validation at each step.
Performance Reality Check: Will Normalization Slow You Down?
Teams often fear that normalization kills performance. The reality is more nuanced. Yes, you’ll do more joins. But modern databases are very good at joins. The performance hit is often overstated, while the cost of anomalies is understated.
One e-commerce case study showed that normalizing a 20-column denormalized orders table increased join count by 2x, but reduced update-correction workload by 5x. Net engineering effort dropped substantially. The biggest gain wasn’t raw speed, it was predictability.
For read-heavy paths, use:
– Composite indexes on (user_id, permission_id)
– Covering indexes for common queries
– Materialized views that pre-join permissions
– Application-level caching of permission sets per user
The DuckDB benchmark showing local analytics outperforming cloud warehouses demonstrates that join performance isn’t the bottleneck, it’s data movement and query planning. Keep your joins local and indexed, and you’ll be fine.
The Deeper Architectural Issue: Configuration as Code
Here’s what nobody wants to admit: your boolean explosion is a symptom of treating configuration as data when it’s actually code. Each flag represents a branching decision in your application logic. You’re encoding business rules as columns, which is why it feels so painful to change.
This is the same architectural inconsistency that costs teams outages, configuration controls system behavior at runtime, yet we apply a fraction of the rigor used for application code. The boolean table is just a manifest of this deeper problem.
When your “data” has 50 tightly coupled boolean flags that control code paths, you’re not modeling entities anymore. You’re modeling a domain-specific language in the shape of a table. That’s not a schema problem, that’s a design problem.
Conclusion: It’s Not About 50, It’s About Semantics
So is 50 flags the tipping point? Only if they represent 50 different ideas.
If you’re building a video platform and those 50 flags are genuinely independent feature toggles, keep them as booleans. Add indexes, document them well, and move on. The simplicity is worth it.
But if you’re building a permission system, a configuration engine, or a rules engine, those booleans are lying to you. They’re pretending to be independent when they’re actually a cohesive domain. Normalize them. Create the join tables. Accept the joins. Your future self will thank you when you need to answer “which users have access to feature X but not feature Y” without writing a 50-line WHERE clause.
The controversy isn’t about the number. It’s about recognizing when your data model has outgrown its prototype clothes. Boolean column #50 isn’t the problem, it’s the warning shot.
Choose your structure based on the questions you need to ask, not the convenience of adding another column.




