Web Application Database Schema Design

Web Application Database Schema Design

Development and maintenance of all types of websites:

Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:

Frequently Asked Questions

Latest works

  • image_website-b2b-advance_0.webp
    B2B ADVANCE company website development
    1419
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1287
  • image_websites_belfingroup_462_0.webp
    Website development for BELFINGROUP
    983
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1245
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    983
  • image_bitrix-bitrix-24-1c_fixper_448_0.webp
    Website development for FIXPER company
    998

Web Application Database Schema Design

A database schema is the foundation that is hardest to change after launch. Improperly normalized tables, missing FKs, or incorrect data types turn into technical debt that accumulates for years. We've encountered this dozens of times: when a project grows and the database starts cracking at the seams, redesign costs many times more. On one project, the client lost two months due to missing indexes — queries took 30 seconds. After implementing a proper schema, everything flew in 50 milliseconds. On average, schema refactoring takes 2–3 days but prevents months of rework. We guarantee that after our work, queries will be tens of times faster.

Main Principles and Common Problems

In 80% of projects, we find the same errors: N+1 queries due to missing foreign keys, index fragmentation when using random UUIDs, loss of precision in financial calculations due to FLOAT, and magic numbers instead of enums. For most applications, the third normal form (3NF) is sufficient: each field depends only on the primary key, without transitive dependencies. Normalization helps avoid redundancy and update anomalies.

Denormalization is justified for counters (comments_count, likes_count) — instead of COUNT JOIN on every query. Also for cached aggregates (monthly order totals) and flattening hierarchical data for search. It is harmful when personal data or frequently changing statuses are duplicated.

Why Choosing the Right Data Type Is a Critical Decision

Errors in data types are one of the most common causes of production issues. Here are typical antipatterns:

-- Bad user_id INT -- will overflow at 2.1 billion records price FLOAT -- precision loss in financial calculations status INT -- magic numbers, no domain constraint created VARCHAR(30) -- string sorting instead of dates settings TEXT -- no structure, no index -- Good user_id BIGINT -- or UUID price DECIMAL(12, 2) -- exact arithmetic status VARCHAR(20) CHECK (status IN ('draft', 'published', 'archived')) created TIMESTAMPTZ -- with timezone settings JSONB -- structured, indexable 

TIMESTAMPTZ stores time in UTC and converts on read according to the session's TimeZone. TIMESTAMP stores "as-is" — when the server timezone changes, data loses meaning.

How to Choose a Primary Key: BIGSERIAL or UUID?

-- SERIAL (auto-increment): simple, compact (8 bytes), predictable id BIGSERIAL PRIMARY KEY -- UUID v4: globally unique, but 16 bytes, random order = index fragmentation id UUID PRIMARY KEY DEFAULT gen_random_uuid() -- ULID via pg_ulid or application-side generation: -- lexicographically sortable by time, 16 bytes id UUID PRIMARY KEY DEFAULT uuid_generate_v7() -- PostgreSQL 17+ 

For most web applications, BIGSERIAL is the optimal choice. UUID is needed when IDs are generated on the client side or to hide predictability.

How to Avoid Common Schema Design Mistakes

The key to success is to think ahead about access patterns. If the application frequently reads the cart with items, use aggregate fields and avoid deep JOINs. For historical data (orders), intentionally denormalize unit_price to preserve a price snapshot. Always check whether the chosen PK supports growth — BIGSERIAL covers 9.2 quintillion records, enough for decades.

Example: E-commerce Schema

CREATE TABLE categories ( id BIGSERIAL PRIMARY KEY, name VARCHAR(200) NOT NULL, slug VARCHAR(220) NOT NULL UNIQUE, parent_id BIGINT REFERENCES categories(id) ON DELETE SET NULL, sort_order INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE products ( id BIGSERIAL PRIMARY KEY, title VARCHAR(500) NOT NULL, slug VARCHAR(520) NOT NULL UNIQUE, category_id BIGINT NOT NULL REFERENCES categories(id) ON DELETE RESTRICT, price DECIMAL(12, 2) NOT NULL CHECK (price > 0), status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')), stock INT NOT NULL DEFAULT 0 CHECK (stock >= 0), specs JSONB, search_vector TSVECTOR, -- for full-text search created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE orders ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'shipped', 'completed', 'cancelled')), total DECIMAL(12, 2) NOT NULL, currency CHAR(3) NOT NULL DEFAULT 'USD', meta JSONB, -- delivery address etc. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE order_items ( id BIGSERIAL PRIMARY KEY, order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE, product_id BIGINT NOT NULL REFERENCES products(id) ON DELETE RESTRICT, quantity INT NOT NULL CHECK (quantity > 0), unit_price DECIMAL(12, 2) NOT NULL, -- price at time of purchase UNIQUE (order_id, product_id) ); 

unit_price is intentional denormalization: the product price will change over time, but the historical price in the order must remain unchanged.

ON DELETE RESTRICT vs CASCADE — rule: CASCADE only when child records are meaningless without the parent (order_items without order). RESTRICT when deleting the parent should be explicitly prevented (cannot delete a category with products).

Which Indexes to Create Immediately and Soft Delete

Add these immediately when creating the schema:

-- FK columns — always, otherwise DELETE parent = seq scan on child table CREATE INDEX idx_products_category_id ON products (category_id); CREATE INDEX idx_order_items_order_id ON order_items (order_id); CREATE INDEX idx_order_items_product_id ON order_items (product_id); -- Frequent filters CREATE INDEX idx_products_status_created ON products (status, created_at DESC); CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC); -- Partial index for active records CREATE INDEX idx_products_published ON products (category_id, created_at DESC) WHERE status = 'published'; -- GIN for JSONB CREATE INDEX idx_products_specs ON products USING GIN (specs); 

Soft delete pattern:

-- Soft delete ALTER TABLE products ADD COLUMN deleted_at TIMESTAMPTZ; CREATE INDEX idx_products_deleted_at ON products (deleted_at) WHERE deleted_at IS NULL; -- Audit table CREATE TABLE audit_log ( id BIGSERIAL PRIMARY KEY, table_name VARCHAR(100) NOT NULL, row_id BIGINT NOT NULL, operation CHAR(1) NOT NULL CHECK (operation IN ('I', 'U', 'D')), old_data JSONB, new_data JSONB, changed_by BIGINT REFERENCES users(id), changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); 

Partial index with WHERE deleted_at IS NULL — active records are indexed separately. Deleted records do not enter the index and do not slow down queries.

Our Process and What's Included

  1. Analysis — identify business entities, relationships, frequent queries.
  2. Design — build an ER diagram, normalize, choose data types.
  3. DDL Creation — SQL scripts with indexes, FKs, constraints.
  4. Documentation — schema description, comments, developer guide.
  5. Audit — review existing schema, identify issues, provide recommendations.

The deliverable includes: ER diagram (up to 15 tables) in PlantUML or Draw.io format, SQL DDL with indexes and constraints, schema documentation (README with table and field descriptions), migration recommendations, and one week of consultation after delivery.

Timelines

Schema design for a new project (up to 15 tables): 1–2 days. Review and refactoring of an existing schema: 1–3 days. The cost is calculated individually — contact us for an estimate. Order schema design — we'll take all nuances into account. Get a professional consultation even if you're unsure about the scope.