How to Handle SaaS Pricing Tiers in Your Database
How to Handle SaaS Pricing Tiers in Your Database
Most SaaS products start with one pricing tier and a simple feature flag: if (isPaid). Then they add a second tier and the code becomes: if (tier === 'pro' || tier === 'enterprise'). By the third tier, you're checking tier levels in dozens of controllers and the logic is hopelessly tangled. Six months later, when marketing wants to test a new pricing structure, the engineering estimate is "four weeks to refactor the entire codebase." This happens because developers treat pricing tiers as a frontend concern instead of a database architecture decision.
This guide covers how to architect SaaS pricing tiers at the database and application layer so they're flexible, auditable, and don't require code changes when marketing experiments with pricing. You'll learn the schema designs that separate successful SaaS products (add a new tier in minutes) from those that ossify around their initial pricing (engineering bottleneck on every pricing change). The focus is on building pricing infrastructure that supports rapid experimentation—because your initial pricing is almost certainly wrong and you need to iterate quickly based on customer feedback.
We'll cover pricing tier data models, feature entitlement systems, usage tracking and metering, quota enforcement, grandfather pricing for existing customers, plan change handling, and how to avoid the common antipatterns that create technical debt. Each section addresses real production requirements that emerge as your pricing evolves.
The Core Pricing Data Model
Your pricing model lives in three related tables: plans, features, and plan_features (the join table). This structure separates what customers can buy (plans) from what capabilities exist in your product (features) and maps between them flexibly. The alternative—hardcoding plan names and features in application code—creates the refactoring nightmare described above.
The plans table defines your pricing tiers: id, name (Starter, Pro, Enterprise), description, price_monthly, price_annual, trial_days, and status (active, archived). The critical field is status—when you deprecate a plan, you mark it archived rather than deleting it. Archived plans don't appear in your pricing page but existing subscriptions continue working. This is how you grandfather existing customers on old pricing while offering new plans to new customers.
The features table catalogs every capability your product offers: id, feature_key (advanced-analytics, api-access, white-label), display_name, description, and feature_type (boolean, quota, metered). The feature_key is your application's internal identifier—your code checks: if (hasFeature('api-access')). Display names and descriptions are for UI and marketing. Feature type determines how the feature works: booleans are on/off, quotas have limits (max 10 users), metered features track usage (per API call).
The plan_features join table maps plans to features: plan_id, feature_id, enabled (boolean), quota_value (for quota features), metadata (JSON for complex config). This is where flexibility comes from. Adding a new feature to Pro tier is one row insert, no code changes. Moving a feature from Enterprise to Pro is updating one row. Creating a new plan is inserting into plans and plan_features, again no code.
| Feature Type | Example | Data Model |
|---|---|---|
| Boolean | API access, white label | enabled: true/false |
| Quota | Max users, max projects | quota_value: integer |
| Metered | API calls, storage used | Track usage separately |
Subscription Schema Design
The subscriptions table links users or organizations to plans and tracks subscription state. Critical fields: id, organization_id or user_id (depending on your B2B vs B2C model), plan_id (foreign key to plans), status (active, trial, past_due, canceled), current_period_start, current_period_end, cancel_at_period_end (boolean), and stripe_subscription_id (if using Stripe).
The plan_id reference is simple but powerful. When checking entitlements, you join subscriptions to plan_features via plan_id to determine what the customer can access. When they upgrade or downgrade, you update plan_id—one field change cascades through all feature checks automatically because they're joined through the foreign key.
The historical record problem: when a customer changes plans, you lose the history of which plan they were on previously. This matters for analytics (how many customers downgraded from Pro to Starter last month?) and customer support (understanding a customer's journey). Solve this with a subscription_history table: subscription_id, plan_id, started_at, ended_at, change_reason. Every plan change inserts a row with the old plan's ended_at and a new row for the new plan. Now you have complete audit trail of plan changes.
Multi-seat subscriptions (common in B2B) require additional modeling. Add a seats field to subscriptions for the number of paid seats, and track actual usage in a subscription_users join table linking users to subscriptions. Your quota enforcement checks: if (subscription.seats >= usersInSubscription.count). When they add a user beyond quota, prompt for seat purchase. Stripe and other billing providers support quantity-based subscriptions natively—the seats value syncs to their system.
Feature Entitlement Service
Your application needs a centralized entitlement service—code that answers "does this user/organization have access to feature X?" This prevents scattered feature checks throughout your codebase and becomes the single source of truth for entitlements.
The basic implementation is a function hasFeature(organizationId, featureKey) that queries: SELECT enabled FROM plan_features WHERE plan_id = (SELECT plan_id FROM subscriptions WHERE organization_id = ? AND status IN ('active', 'trial')) AND feature_id = (SELECT id FROM features WHERE feature_key = ?). If enabled is true, grant access. This query joins subscriptions to plan_features through plan_id, implementing the indirection that makes pricing changes easy.
Caching is essential for performance. Feature entitlements change rarely (only when subscriptions or plans change) but are checked frequently (potentially every request). Cache the entire feature set per organization in Redis with a TTL of 5-10 minutes: cache.set(`org:${orgId}:features`, featureSet, 600). On hasFeature calls, check cache first. On subscription changes, invalidate the cache for that organization. This reduces database queries from hundreds per request to essentially zero in steady state.
The quota variant getFeatureQuota(organizationId, featureKey) returns the quota value for quota-type features: SELECT quota_value FROM plan_features WHERE .... For example, getFeatureQuota(org, 'max-users') might return 10 for Starter, 50 for Pro, unlimited (-1 or null) for Enterprise. Your code checks current usage against the quota: if (currentUsers >= quota && quota !== -1) { return 'quota exceeded' }.
The pattern for usage limits is checking before actions, not after. When a user tries to add a team member, check the quota first and show an upgrade prompt if exceeded. Don't let them add the user then retroactively block access—this creates bad UX and data cleanup issues. Preventive quota checks keep your data consistent.
Usage Tracking and Metering
Metered features (API calls, storage, compute time) require tracking actual usage per organization. This data feeds into billing (for usage-based pricing), quota enforcement (free tier gets 1000 API calls/month), and product analytics (which customers are power users).
The usage_events table records consumption: id, organization_id, feature_key, quantity (how much was consumed), event_time, and metadata (JSON for event-specific data). Every API call, file upload, or metered action creates a row. This table grows large quickly—millions of rows for active products—so partition it by time (monthly partitions) and archive old data to cold storage after billing cycles close.
Real-time usage tracking uses counters in Redis for performance. Increment a counter on each event: redis.incr(`usage:${orgId}:api-calls:${month}`), then periodically (hourly or daily) flush counters to the usage_events table and reset them. This batches database writes, reducing load. For quota enforcement, check the Redis counter—if it exceeds quota, block the action. The usage_events table becomes the permanent record for billing and analytics, while Redis provides fast operational checks.
Rolling windows vs calendar periods matters for quota enforcement. Calendar periods (1000 calls per month, resetting on the 1st) are simpler but create usage spikes at period boundaries—customers race to use their quota before it resets. Rolling windows (1000 calls in the last 30 days) smooth usage but require summing events over time windows: SELECT SUM(quantity) FROM usage_events WHERE organization_id = ? AND feature_key = 'api-calls' AND event_time > NOW() - INTERVAL '30 days'. This query is expensive at scale; use materialized views or pre-aggregated usage tables to optimize it.
| Quota Type | Reset Behavior | Implementation |
|---|---|---|
| Calendar period | Monthly on fixed date | Simple counter, reset on schedule |
| Rolling window | Continuous (last 30 days) | Sum events in time window |
| Per-billing-cycle | Aligned to subscription period | Counter reset on renewal date |
Plan Changes and Upgrades
Handling plan changes correctly requires updating subscriptions, recalculating quotas, updating billing, and managing the transition period. The database changes are straightforward but the business logic around timing and proration is complex.
Immediate upgrades take effect instantly: update subscriptions SET plan_id = new_plan_id, current_period_start = NOW() WHERE id = subscription_id. The customer gets access to new features immediately, and you bill the prorated difference. This is standard for upgrades—customers paying more expect immediate value. Your entitlement service automatically grants new features because it queries through the updated plan_id.
Scheduled downgrades are better UX than immediate ones. When a customer downgrades from Pro to Starter, don't immediately revoke their Pro features—they paid for the full month. Instead, schedule the downgrade for the next billing period: update subscriptions SET scheduled_plan_id = new_plan_id, cancel_current_plan_at_period_end = true. Your entitlement service checks: use scheduled_plan_id if current_period_end has passed, otherwise use plan_id. On the period end date, a background job executes the plan change: update subscriptions SET plan_id = scheduled_plan_id, scheduled_plan_id = null.
The quota transition problem occurs when downgrading from higher to lower quotas. If a customer on Pro (50 users) downgrades to Starter (10 users) but currently has 30 users, what happens? The graceful approach is allow_overage: let them keep the 30 users but prevent adding more. Show a banner: "You're using 30 users on a 10-user plan. Please remove 20 users or upgrade." The strict approach is force_compliance: on downgrade, immediately block access for users beyond the new quota (require them to choose which 10 users keep access). Most SaaS products use allow_overage with nagging to avoid the support burden of strict enforcement.
Grandfather Pricing and Legacy Plans
As your product evolves, you'll change pricing—raise prices, restructure tiers, add or remove features. Existing customers expect to keep their current pricing (grandfather pricing) unless they choose to change. This requires treating plans as versioned entities rather than mutable configurations.
The implementation is never updating plan pricing for existing active plans. When raising prices, create new plan records: INSERT INTO plans (name, price_monthly, status) VALUES ('Pro', 99, 'active'). Mark the old Pro plan as archived: UPDATE plans SET status = 'archived' WHERE id = old_pro_id. Existing subscriptions continue referencing the old plan_id at $79/month, new subscriptions get the new plan at $99/month. Your pricing page queries: SELECT * FROM plans WHERE status = 'active' to show only current offerings.
Plan versioning takes this further. Add a version column to plans and a is_current_version boolean. When restructuring pricing, create v2 of all plans as new records. Existing customers stay on v1, new customers get v2. Your analytics can segment behavior by plan version: are v2 customers retaining better than v1? This answers whether your pricing changes improved product-market fit.
Migration offers let you incentivize legacy customers to switch to new pricing. Add a migration_offer table: legacy_plan_id, new_plan_id, discount_percent, expires_at. When a legacy customer views your pricing page, show: "Special offer: Upgrade to new Pro for $89/month (normally $99) available until June 1." This clears out legacy plans over time without forcing customers, reducing operational complexity of maintaining multiple plan versions indefinitely.
Custom Plans and Enterprise Pricing
Enterprise customers often need custom pricing—negotiated rates, bespoke feature combinations, contract-specific terms. Forcing these into your standard plan structure creates chaos. Instead, support custom plans explicitly in your data model.
The custom_plans table extends base plans: id, organization_id (which customer this applies to), base_plan_id (the standard plan it's based on), custom_price, custom_billing_period, contract_start, contract_end, and notes (terms and conditions). When checking entitlements for a customer with a custom plan, check the custom plan first, fall back to the base plan for features not overridden.
Custom feature toggles per customer use a customer_features table: organization_id, feature_id, enabled, quota_value, metadata. This overrides plan_features for specific customers. For example, an enterprise customer on the Pro base plan gets white-label features (normally Enterprise-only) via a row in customer_features. Your entitlement service queries: check customer_features first for org-specific overrides, then fall back to plan_features.
The billing integration challenge is these custom plans typically don't sync to Stripe or standard billing systems. You're invoicing manually or using enterprise billing systems. Track this in the subscriptions table with billing_type: 'stripe', 'manual', 'contract'. Manual billing subscriptions bypass automatic charges; you generate invoices from custom plan data and send them through your accounting system. This is acceptable for enterprise customers (tens of them) but doesn't scale to hundreds.
Pricing Experiments and A/B Testing
Testing pricing changes before rolling them out broadly reduces the risk of revenue-destroying pricing mistakes. Your database schema needs to support showing different pricing to different customer segments without creating permanent data complications.
The experiments table tracks active tests: id, experiment_key (pricing-test-2024-q1), variant (control, variant_a, variant_b), start_date, end_date, and target_segment (new customers, all customers, specific cohorts). Each variant references a different plan_id. When a new user signs up during an active experiment, assign them to a variant randomly (or based on segment criteria) and record it: INSERT INTO experiment_assignments (organization_id, experiment_id, variant).
Displaying pricing based on experiment assignment requires querying the assignment before showing the pricing page. The flow: user visits pricing → check experiment_assignments for active experiments → if assigned to variant, show that variant's plans → user subscribes to shown plan. The critical constraint is consistency—once assigned to a variant, always show that variant. Don't re-randomize on page refreshes or the user sees different prices each visit.
The analysis infrastructure compares conversion rates and retention across variants: SELECT variant, COUNT(*) as signups, COUNT(subscription_id) as conversions FROM experiment_assignments LEFT JOIN subscriptions ON ... GROUP BY variant. After statistical significance (typically 100+ conversions per variant), choose the winning variant and make it the default. Archive losing variants' plans and end the experiment.
Performance and Caching Strategies
Entitlement checks happen frequently—potentially every API request—so performance is critical. Naive implementations that query the database on every check create terrible performance and database load. Multi-layer caching solves this.
Application-level caching stores the complete feature set per organization in memory. On application startup or first access, query plan_features for each active organization and cache in a hash map: featureCache[orgId][featureKey] = { enabled, quotaValue }. Subsequent checks are hash lookups—microseconds instead of milliseconds. Invalidate this cache when plans or subscriptions change. This works for applications with relatively few organizations (hundreds to low thousands) where the cache fits in memory.
Redis caching works for larger scale. Store serialized feature sets in Redis: redis.setex(`features:${orgId}`, 300, JSON.stringify(features)). Check Redis first, hit database on miss and populate cache. Set TTL to 5 minutes so changes propagate reasonably fast. On subscription or plan changes, invalidate the relevant cache keys immediately: redis.del(`features:${orgId}`). This handles millions of organizations because only active ones are cached.
The edge case is plan changes during an active session. User upgrades to Pro, gaining API access. Without cache invalidation, they won't get API access for up to 5 minutes (your TTL). The solution is publishing plan change events. When a subscription updates, publish an event to a message queue or Redis pub/sub. Application servers subscribe and invalidate their local caches immediately. This provides near-instant feature propagation without constant database polling.
Frequently Asked Questions
How do I handle annual vs monthly pricing in the database?
Add both price_monthly and price_annual to the plans table. Also add a billing_period field to subscriptions (monthly, annual). When creating a subscription, the user chooses monthly or annual billing for their selected plan, which determines which price they pay and their billing cycle. The entitlement service doesn't care about billing period—an organization on Pro annual gets the same features as Pro monthly. The billing integration uses billing_period and the appropriate price field to charge correctly. For analytics, track which billing periods convert and retain better—annual typically has better retention because of the psychological commitment and lower churn from billing failures.
Should I store pricing in cents or dollars in the database?
Store prices in cents (or smallest currency unit) as integers, never as floats. Floating-point arithmetic has precision errors that cause billing bugs—1.99 times 3 might equal 5.969999998 instead of 5.97. Integer cents avoid this entirely: 199 times 3 equals 597, always. The database column type should be INTEGER or BIGINT for price_cents. Convert to dollars only in display layer: displayPrice = priceCents / 100. This also simplifies multi-currency handling—store price_cents and currency_code (USD, EUR) separately, perform calculations in cents, convert to decimal for display. Never use FLOAT or DOUBLE for money.
How do I implement add-on features that cost extra on any plan?
Create a plan_addons table: id, name, description, price_monthly, feature_id. Then create subscription_addons: subscription_id, addon_id, quantity, added_at. When checking entitlements, query both plan_features (included in base plan) and subscription_addons (purchased separately) and merge them. For example, base Pro plan includes 50 users, customer purchases "Additional Users" addon for 25 more users, quota calculation is base + addon = 75 users. In billing, sum the base plan price and all addon prices. This lets customers customize their plan beyond standard tiers while keeping your core plans simple.
What's the best way to implement soft vs hard quota limits?
Add a quota_enforcement column to features table: 'soft', 'hard', or 'none'. Hard limits prevent the action when quota is exceeded—attempting to add an 11th user when quota is 10 returns an error immediately. Soft limits allow overages with warnings—you can add the 11th user but see a banner prompting upgrade. Track overage amounts in a usage_overages table for billing or enforcement decisions. The UX difference is significant: hard limits frustrate users during critical moments (demo, deadline), while soft limits with persistent upgrade prompts maintain goodwill while driving upgrades. Use soft limits for most quotas, hard limits only for resource-constrained features (API rate limits, storage) where overages cost you money.
How should I version my pricing when restructuring tiers?
Add a plan_version column to the plans table. When restructuring, create all new plans with version = 2, mark old plans as archived but don't delete them. Update your pricing page query to show only current version: WHERE status = 'active' AND plan_version = (SELECT MAX(plan_version) FROM plans). Existing subscriptions keep referencing v1 plans. For migrations, create a planned_migrations table allowing customers to opt into v2 pricing at their next renewal. This preserves grandfather pricing while cleaning up legacy plans gradually. Track metrics split by plan version to measure whether v2 improves your business metrics.
How do I handle proration when changing plans mid-cycle?
Your billing provider (Stripe) typically handles proration calculation automatically—when changing from $50 plan to $100 plan mid-month, they calculate unused time on old plan ($25 credit) and charge full new plan ($100), netting a $75 charge. In your database, just record the plan change and new period: UPDATE subscriptions SET plan_id = new_plan, current_period_start = NOW(). The invoice details from your billing provider show proration math. For manual billing or custom plans, calculate proration yourself: unused_value = old_price * (days_remaining / days_in_period), then charge new_price - unused_value immediately and start billing new_price on schedule.
What's the right approach for seat-based pricing where seats are shared?
Store seats as quantity on the subscription: subscriptions.seats = 10. Track individual user access in a subscription_users table with seat assignment. When seats are shared/transferable, removing a user frees their seat immediately for reassignment. When non-transferable (concurrent user licenses), track active sessions to enforce concurrent seat limits. Query: SELECT COUNT(DISTINCT user_id) FROM active_sessions WHERE organization_id = ? to count concurrent users and compare against seats. Block new logins when at capacity. The subscription_users table becomes a historical record of who had access when, important for compliance and auditing even if seats are transferable.
How should I implement usage-based pricing that charges overages?
Base plans include a quota (1000 API calls/month). Track usage in usage_events table. At billing time (end of period), calculate overage: total_usage - quota. If positive, charge overage_rate * overage_quantity. Store this in an invoice_line_items table: subscription_id, description, quantity, unit_price, total. Your billing integration generates invoices with line items for base subscription and overages separately. The challenge is communicating overage risk—send alerts when customers reach 80% of quota so bills aren't surprising. For recurring overages, prompt plan upgrade to higher quota tier instead of continually billing overages.
How do I handle feature deprecation when features are removed from all plans?
Mark the feature as deprecated but don't delete it: UPDATE features SET status = 'deprecated', deprecated_at = NOW(). Remove it from plan_features for all plans so new subscriptions don't get it. Existing customers who were using the feature keep access through customer_features overrides—grant them explicit access to the deprecated feature. Set a sunset_date on deprecated features and communicate the timeline to affected customers. After sunset, your application stops checking the feature flag entirely and removes the code. This graceful deprecation process prevents breaking existing customers while cleaning up technical debt.
What's the best way to implement free trials with different tiers?
Store trial_plan_id separately from plan_id on subscriptions during trials: subscriptions.status = 'trial', trial_plan_id = pro_plan_id, plan_id = NULL. The trial grants access to features based on trial_plan_id. When trial converts to paid, they choose which plan to actually subscribe to—might downgrade to Starter or upgrade to Enterprise. UPDATE subscriptions SET status = 'active', plan_id = chosen_plan_id on conversion. This separates trial experience (typically generous to showcase product value) from post-trial reality (customer pays for what they need). Track conversion rates by trial_plan_id to optimize which tier to trial.
Conclusion
Database architecture for SaaS pricing determines whether you can iterate on pricing quickly or whether every pricing change requires engineering sprints. The pattern that enables rapid iteration is indirection—plans, features, and plan_features tables that decouple product capabilities from pricing tiers. Add features to plans via data, not code. Track usage for quota enforcement and billing in usage_events, aggregated efficiently for performance. Version plans rather than mutating them to support grandfather pricing naturally. Cache entitlements aggressively to handle the high-frequency feature checks without database load. The upfront investment in flexible pricing architecture pays dividends every time marketing wants to test new pricing—you say "it'll be live in an hour" instead of "we need a two-week sprint."