How to Design a Database Schema for Your SaaS MVP in 2026: A Decision Framework for Solo Founders
You’ve sketched your SaaS idea on a whiteboard — users, accounts, billing, some core feature. Now you need a database schema. The temptation is to over-design: normalize everything to 5NF, plan for millions of users, add every index you can think of. The reality is that 80% of schema decisions made in week one get rewritten by month six as product direction shifts.
This framework isn’t about academic database design principles. It’s about making the minimum set of structural decisions that won’t block your development velocity or require painful migrations later.
Quick Answer
Start with a single Postgres database, a users table (using UUIDs, not auto-increment IDs), an accounts/organizations table if you have multi-tenant users, and a subscriptions table that references your payment provider’s IDs. Use JSONB columns for flexible attribute storage (pricing plans, user preferences, feature flags) — you can normalize them into proper tables once the data model stabilizes. Skip composite indexes until you have query EXPLAIN output to justify them.
Why This Matters in 2026
Three trends make schema design decisions particularly impactful this year:
- AI-generated code — The next generation of development tools (Cursor, Aider, Copilot) produce better code when your schema is self-documenting. Clear table and column names with correct types reduce hallucinations in AI-generated queries.
- Serverless database adoption — Your 2026 schema needs to work with Neons scale-to-zero or Planetscales branching workflow. Complex relational designs with heavy trigger usage and custom data types may not port cleanly to serverless databases.
- Pricing visibility — Every query you write has a measurable API cost on serverless databases. A schema that requires 5 JOINs for a simple dashboard view costs more than one that surfaces the same data in 2-3 JOINs.
Common Schema Mistakes Solo Founders Make
Mistake 1: Auto-Increment IDs Everywhere
Auto-increment integers are fine for internal joins but break in distributed databases, expose record count to users, and create migration pain when you move from Postgres to a serverless alternative. Use UUID v7 (time-sorted UUIDs that support indexing) for all primary keys. The 16-byte storage cost is negligible at startup scale and saves migration pain later.
Mistake 2: Premature Normalization
Splitting user settings into 5 tables (notification_preferences, display_preferences, privacy_settings…) before you know which settings you actually need creates pain for every feature iteration. Use JSONB columns for flexible settings, then normalize once the schema stabilizes after 3-6 months of real usage data.
Mistake 3: No Audit Columns
Every table should have created_at, updated_at, and optionally deleted_at. These columns save hours of debugging — “when did this record change?” is the most common question you can’t answer if you didn’t track timestamps.
Minimum Viable Schema Template
Core Tables for Any SaaS
| Table | Key Columns | Purpose |
|---|---|---|
users |
id (UUID v7), email, name, auth_provider_id, avatar_url, created_at, updated_at | Authentication and user identity |
accounts |
id (UUID v7), name, slug, billing_email, plan_tier, created_at, updated_at | Multi-tenant account/org structure |
account_members |
account_id, user_id, role (enum: admin/member/viewer), joined_at | User-to-account membership |
subscriptions |
id, account_id, provider (Stripe/LemonSqueezy), provider_subscription_id, status, current_period_start, current_period_end | Billing subscription tracking |
feature_flags |
account_id, flag_name (varchar), enabled (boolean), metadata (JSONB) | Per-account feature rollout |
Optional but Recommended
| Table | Key Columns | Purpose |
|---|---|---|
audit_logs |
id, actor_id, action (varchar), target_type, target_id, changes (JSONB), created_at | Track all state-changing operations for debugging and compliance |
api_keys |
id, account_id, key_hash (not plaintext), name, scopes (JSONB), expires_at, created_at | API access for integrations and programmatic usage |
webhook_endpoints |
id, account_id, url, secret, enabled_events (text[]), created_at | Webhook delivery configuration |
Indexing Strategy (Start Minimal)
- Always create: Primary key index (auto), unique index on
users.email, composite index onaccount_members(account_id, user_id), index onsubscriptions.account_id - Add when needed: Composite indexes for specific queries you’ve profiled. Use
EXPLAIN ANALYZEon slow queries before adding indexes — premature indexing adds write overhead without query benefit. - Avoid: Indexes on boolean columns, indexes on JSONB paths until confirmed in EXPLAIN output, and covering indexes until you have actual query performance data.
Migration Strategy
Use additive migrations whenever possible. Adding a column or table is reversible. Dropping or renaming a column requires coordination. Rules:
- Always add columns as
nullablefirst, backfill data, then add NOT NULL constraint. - Never rename columns — add the new column, dual-write for one release cycle, then drop the old column.
- Use Drizzle Kit or Prisma Migrate for auto-generated migration SQL, but always review the SQL before applying in production.
Limitations of This Framework
- Not for time-series data — If your app tracks events, metrics, or logs at high volume (>100K events/day), consider a time-series database (TimescaleDB, InfluxDB) or a dedicated log service. Postgres can handle 100K rows/table but query performance degrades without careful partitioning.
- Not for full-text search-heavy apps — Postgres full-text search works for basic search, but if your app’s primary feature is search across millions of documents, you need Meilisearch, Typesense, or Elasticsearch alongside Postgres.
- Not for real-time collaboration — If your app requires live multi-user editing (like Notion or Figma), the schema needs CRDT-aware data structures. This framework assumes a conventional request-response data model.
- UUID performance — UUID v7 is better than v4 for index performance but still slightly slower than integer IDs on inserts (~5-10% overhead tested on Postgres 17). For most indie SaaS apps, this is negligible.
The Bottom Line
Your MVP schema should be minimal, additive, and self-documenting. Use UUIDs, JSONB for flexible attributes, and additive migrations. Don’t design for scale you don’t have — design for developer speed and easy refactoring. A schema that takes two hours to design and gets rewritten in month three is cheaper than a schema that takes two days to design and locks you into choices that don’t fit your actual product.
FAQ
Should I use an ORM or raw SQL for schema management?
Use an ORM (Drizzle or Prisma) for schema definition and migration management. They provide type-safe schema files that double as documentation. For complex queries that the ORM can’t optimize, drop to raw SQL. The hybrid approach (ORM-defined schema + raw queries for complex reports) is the sweet spot.
How do I handle soft deletes?
Add a deleted_at timestamp column with a partial index: CREATE INDEX idx_table_active ON table WHERE deleted_at IS NULL. This keeps query performance fast for active records. For hard deletes, cascade through related tables manually. SQLAlchemy and Drizzle have built-in support for soft delete query scoping.
When should I split into separate databases?
At the scale of 100K+ active users or 50GB+ of data. Before that, a single Postgres database with well-indexed tables handles everything. The cost and operational complexity of multi-database architecture is not justified until you have measurable performance problems.
Should I use enums or VARCHAR for status columns?
Postgres enums are type-safe and self-documenting, but adding a new enum value requires a migration. VARCHAR with CHECK constraints gives similar type safety but is easier to extend. For indie MVPs, VARCHAR with a CHECK constraint is the pragmatic choice — you’ll add states as your product evolves.
How do I model billing in the database?
Store the payment provider’s subscription ID (stripe_subscription_id) and provider_customer_id in your subscriptions table. Sync with Stripe webhooks for status changes. Don’t duplicate Stripe’s pricing data in your database — query it from Stripe’s API or use Stripe’s webhook cache. Only store what you need for authorization logic (plan tier, current period end, status).
What about data privacy regulations (GDPR, CCPA)?
Design for data deletion from day one. Every table should support finding and deleting records by user ID. The users table should support anonymization (clear PII fields but retain analytics records with a NULL user_id). Plan for right-to-deletion requests as a data export + delete script, not a manual SELECT-then-DELETE operation.