Key Takeaways

  • PostgreSQL now leads developer adoption at 55.6% vs MySQL's 40.5% — both dialects are covered in every schema below (Stack Overflow 2025).
  • Store the price at time of purchase in order_items, not as a foreign key back to products.price — a price change would silently corrupt historical order totals.
  • PostgreSQL doesn't auto-index foreign keys; MySQL does. Add explicit indexes on FK columns you'll use in WHERE or JOIN clauses in PostgreSQL.
  • Use TIMESTAMPTZ (not TIMESTAMP) in PostgreSQL for all audit columns — it stores UTC and converts per session timezone automatically.
  • A self-referencing parent_id handles threading (comments, sub-tasks) without a second table, at the cost of recursive queries for deep hierarchies.
A software engineer at a laptop planning database schema design for a relational application
Schema design decisions — table structure, foreign keys, indexes — made at the start define the ceiling for everything built on top. (Photo: Christina Morillo / Pexels)
Most-Used Databases — Stack Overflow Developer Survey 2025 PostgreSQL 55.6% MySQL 40.5% SQLite 34.3% MongoDB 26.1% Redis 22.4% Source: Stack Overflow Developer Survey 2025 (n = 65,437 respondents)

PostgreSQL overtook MySQL in developer adoption in 2025 — a full reversal from its 33% share when it first appeared in the survey in 2018 (Stack Overflow, 2025).

1. E-Commerce Schema

A production e-commerce schema typically requires joining at least 5 tables even for a simple product listing query — categories, products, customers, orders, and order line items. The highest-leverage design decision isn't which ORM to use; it's whether you snapshot the price on each order line. You must. Don't look it up from products at query time, or a price change will silently rewrite every historical order total.

Storing price_at_purchase on order_items rather than referencing products.price is the single most important design decision in an e-commerce schema. A price update on the product row should affect future orders only — the historical record must be immutable to keep accounting correct.

Key design decisions

  • price_at_purchase on order_items stores the price snapshot at sale time, not a live foreign key to the current product price.
  • status as a VARCHAR ENUM-equivalent — use ENUM in MySQL or a CHECK constraint in PostgreSQL for tighter validation at the database level.
  • Soft-delete via deleted_at on products lets you retire a product without breaking order history. Every query must filter WHERE deleted_at IS NULL, so use it only where you genuinely need the audit trail.
-- MySQL
CREATE TABLE categories (
    id         INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name       VARCHAR(100) NOT NULL,
    slug       VARCHAR(100) NOT NULL UNIQUE,
    created_at TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE products (
    id          INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    category_id INT UNSIGNED NOT NULL,
    name        VARCHAR(255) NOT NULL,
    slug        VARCHAR(255) NOT NULL UNIQUE,
    price       DECIMAL(10,2) NOT NULL,
    stock       INT UNSIGNED  NOT NULL DEFAULT 0,
    deleted_at  TIMESTAMP NULL,
    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (category_id) REFERENCES categories(id)
);

CREATE TABLE customers (
    id         INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    email      VARCHAR(255) NOT NULL UNIQUE,
    name       VARCHAR(255) NOT NULL,
    created_at TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE orders (
    id          INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    customer_id INT UNSIGNED NOT NULL,
    status      VARCHAR(20)  NOT NULL DEFAULT 'pending',
    total       DECIMAL(10,2) NOT NULL DEFAULT 0.00,
    created_at  TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (customer_id) REFERENCES customers(id)
);

CREATE TABLE order_items (
    id                INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    order_id          INT UNSIGNED  NOT NULL,
    product_id        INT UNSIGNED  NOT NULL,
    quantity          INT UNSIGNED  NOT NULL,
    price_at_purchase DECIMAL(10,2) NOT NULL,
    FOREIGN KEY (order_id)   REFERENCES orders(id),
    FOREIGN KEY (product_id) REFERENCES products(id)
);

2. Blog / CMS Schema

The most common modelling mistake in blog schemas is treating posts-to-tags as one-to-many. It isn't. A post can have many tags, and a tag applies to many posts. That's many-to-many, and it needs a junction table. The schema below adds post_tags with a composite primary key. It also handles threaded comments with a parent_id self-reference, and draft/scheduled posts via a nullable published_at.

A post_tags junction table with PRIMARY KEY (post_id, tag_id) and ON DELETE CASCADE on both foreign keys is the standard SQL pattern for many-to-many post-to-tag relationships. The composite primary key prevents duplicate tag associations, and the index is created automatically alongside it.

Key design decisions

  • published_at as a nullable timestamp handles scheduling — NULL means draft. Your application checks WHERE published_at <= NOW() to determine what's live.
  • post_tags is the junction table for the many-to-many relationship. The composite PRIMARY KEY (post_id, tag_id) prevents duplicates and doubles as a covering index.
  • parent_id on comments enables threaded replies without a separate table. Deep nesting requires a recursive CTE in PostgreSQL or application-side tree logic in MySQL pre-8.0.
-- MySQL
CREATE TABLE authors (
    id           INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    email        VARCHAR(255) NOT NULL UNIQUE,
    display_name VARCHAR(100) NOT NULL,
    bio          TEXT,
    created_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE categories (
    id   INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    slug VARCHAR(100) NOT NULL UNIQUE
);

CREATE TABLE posts (
    id           INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    author_id    INT UNSIGNED  NOT NULL,
    category_id  INT UNSIGNED  NULL,
    title        VARCHAR(255)  NOT NULL,
    slug         VARCHAR(255)  NOT NULL UNIQUE,
    body         LONGTEXT      NOT NULL,
    published_at TIMESTAMP     NULL,
    created_at   TIMESTAMP     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at   TIMESTAMP     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (author_id)   REFERENCES authors(id),
    FOREIGN KEY (category_id) REFERENCES categories(id)
);

CREATE TABLE tags (
    id   INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50)  NOT NULL UNIQUE,
    slug VARCHAR(50)  NOT NULL UNIQUE
);

CREATE TABLE post_tags (
    post_id INT UNSIGNED NOT NULL,
    tag_id  INT UNSIGNED NOT NULL,
    PRIMARY KEY (post_id, tag_id),
    FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
    FOREIGN KEY (tag_id)  REFERENCES tags(id)  ON DELETE CASCADE
);

CREATE TABLE comments (
    id         INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    post_id    INT UNSIGNED NOT NULL,
    parent_id  INT UNSIGNED NULL,
    author     VARCHAR(100) NOT NULL,
    body       TEXT         NOT NULL,
    created_at TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (post_id)   REFERENCES posts(id)    ON DELETE CASCADE,
    FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE SET NULL
);

3. SaaS User & Subscription Schema

In the 2025 Stack Overflow Developer Survey (65,437 respondents), PostgreSQL reached 55.6% adoption versus MySQL's 40.5% (Stack Overflow, 2025). That shift shows up most clearly in SaaS work. TIMESTAMPTZ, native UUID support, and row-level security map cleanly to multi-tenant requirements. This schema models organisations, members, plans, and subscriptions with the same price-snapshot discipline as the e-commerce example.

According to the Stack Overflow Developer Survey 2025 (n = 65,437), PostgreSQL is used by 55.6% of developers versus MySQL's 40.5% — the first time PostgreSQL has held a clear lead. It ranks first in most-admired (65%) and most-desired (46%) database for the third year running, making it the natural default for new SaaS projects (Stack Overflow, 2025).

Key design decisions

  • role on membershipsowner, admin, member controls access within an organisation without a separate permissions table at this scale.
  • renews_at on subscriptions drives billing reminders and access revocation at the application layer.
  • price_per_month is duplicated on subscriptions for the same reason as price_at_purchase on order_items — changing a plan's price shouldn't alter what existing subscribers are charged.
-- PostgreSQL
CREATE TABLE users (
    id         SERIAL PRIMARY KEY,
    email      VARCHAR(255) NOT NULL UNIQUE,
    name       VARCHAR(255) NOT NULL,
    created_at TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

CREATE TABLE organisations (
    id         SERIAL PRIMARY KEY,
    name       VARCHAR(255) NOT NULL,
    slug       VARCHAR(100) NOT NULL UNIQUE,
    created_at TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

CREATE TABLE memberships (
    id              SERIAL PRIMARY KEY,
    user_id         INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    organisation_id INT NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
    role            VARCHAR(20) NOT NULL DEFAULT 'member',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (user_id, organisation_id)
);

CREATE TABLE plans (
    id              SERIAL PRIMARY KEY,
    name            VARCHAR(100)   NOT NULL,
    price_per_month NUMERIC(10, 2) NOT NULL,
    max_members     INT            NOT NULL
);

CREATE TABLE subscriptions (
    id              SERIAL PRIMARY KEY,
    organisation_id INT            NOT NULL REFERENCES organisations(id),
    plan_id         INT            NOT NULL REFERENCES plans(id),
    price_per_month NUMERIC(10, 2) NOT NULL,
    status          VARCHAR(20)    NOT NULL DEFAULT 'active',
    renews_at       TIMESTAMPTZ    NOT NULL,
    created_at      TIMESTAMPTZ    NOT NULL DEFAULT NOW()
);

4. Task Tracker Schema

Task schemas look deceptively simple until sub-tasks, assignees, and labels enter the picture. The parent_id self-reference on tasks handles sub-tasks in a single table. Labels are scoped to a project via a foreign key, then linked to individual tasks through a junction table. The cascade rules here matter: deleting a project removes its tasks; removing a label removes the association, not the task itself.

The self-referencing parent_id pattern handles subtask nesting within a single tasks table. Setting ON DELETE SET NULL on the parent_id foreign key preserves orphaned sub-tasks as independent items when a parent is deleted, rather than silently cascading the delete down the tree.

-- MySQL
CREATE TABLE projects (
    id         INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name       VARCHAR(255) NOT NULL,
    created_at TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE users (
    id         INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    email      VARCHAR(255) NOT NULL UNIQUE,
    name       VARCHAR(255) NOT NULL
);

CREATE TABLE tasks (
    id          INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    project_id  INT UNSIGNED NOT NULL,
    parent_id   INT UNSIGNED NULL,
    assigned_to INT UNSIGNED NULL,
    title       VARCHAR(255) NOT NULL,
    description TEXT,
    status      VARCHAR(20)  NOT NULL DEFAULT 'todo',
    due_date    DATE         NULL,
    created_at  TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (project_id)  REFERENCES projects(id) ON DELETE CASCADE,
    FOREIGN KEY (parent_id)   REFERENCES tasks(id)    ON DELETE SET NULL,
    FOREIGN KEY (assigned_to) REFERENCES users(id)    ON DELETE SET NULL
);

CREATE TABLE labels (
    id         INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    project_id INT UNSIGNED NOT NULL,
    name       VARCHAR(50)  NOT NULL,
    color      CHAR(7)      NOT NULL DEFAULT '#cccccc',
    FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);

CREATE TABLE task_labels (
    task_id  INT UNSIGNED NOT NULL,
    label_id INT UNSIGNED NOT NULL,
    PRIMARY KEY (task_id, label_id),
    FOREIGN KEY (task_id)  REFERENCES tasks(id)  ON DELETE CASCADE,
    FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE
);

5. Messaging / Chat Schema

The key structural choice in a messaging schema is whether direct messages and group conversations share a table. A unified conversations table with an is_group flag means one messages table serves both cases — fewer joins, simpler queries. The conversation_members junction table connects users to conversations and works identically for 1-on-1 and group chats.

A unified conversations table with is_group BOOLEAN handles both direct messages and group chats through the same foreign key path. The conversation_members junction table enforces that a user must be a member of a conversation before they can read or write to it — a constraint you can push down to row-level security in PostgreSQL rather than enforce entirely in application code.

-- PostgreSQL
CREATE TABLE users (
    id         SERIAL PRIMARY KEY,
    email      VARCHAR(255) NOT NULL UNIQUE,
    username   VARCHAR(50)  NOT NULL UNIQUE,
    created_at TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

CREATE TABLE conversations (
    id         SERIAL PRIMARY KEY,
    name       VARCHAR(255) NULL,  -- NULL for direct messages
    is_group   BOOLEAN      NOT NULL DEFAULT FALSE,
    created_at TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

CREATE TABLE conversation_members (
    conversation_id INT         NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
    user_id         INT         NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    joined_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (conversation_id, user_id)
);

CREATE TABLE messages (
    id              SERIAL PRIMARY KEY,
    conversation_id INT         NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
    sender_id       INT         NOT NULL REFERENCES users(id),
    body            TEXT        NOT NULL,
    sent_at         TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Tables per Schema — All 5 Examples 6 5 4 5 E-Commerce 6 Blog / CMS 5 SaaS 5 Task Tracker 4 Messaging Source: schemas in this article (sql-designer.com, 2026)

Blog/CMS is the most complex at 6 tables due to the post_tags junction table. Messaging is the leanest at 4 — the unified conversations model keeps join depth low.

Tips for Adapting These Schemas

  • Add updated_at columns wherever you need to detect changes. In MySQL, use ON UPDATE CURRENT_TIMESTAMP. In PostgreSQL, create a trigger — the column doesn't update automatically.
  • Use TIMESTAMPTZ in PostgreSQL over TIMESTAMP. It stores UTC and converts correctly per session timezone, which matters the moment your users span more than one timezone.
  • Index FK columns in PostgreSQL manually. MySQL creates indexes on foreign key columns automatically; PostgreSQL doesn't. Add an explicit index on any FK column you'll use in a WHERE or JOIN.
  • Think twice before adding soft deletes. A deleted_at column requires WHERE deleted_at IS NULL in every query. One forgotten filter is a data leak waiting to happen. Use it only where the audit trail is genuinely worth that cost.
  • Design visually first — missing relationships and redundant tables are obvious in a diagram and invisible in a wall of DDL. Use the SQL Designer demo to drag, connect, and adjust before you commit to code.
  • Check DDL syntax when targeting a different database. The CREATE TABLE syntax for primary keys, boolean types, timestamp defaults, and ALTER TABLE differs significantly between MySQL, PostgreSQL, Oracle, SQL Server, and SQLite. For a full side-by-side reference, see the DDL syntax comparison guide.

Frequently Asked Questions

Should I use MySQL or PostgreSQL for a new project in 2026?

PostgreSQL is the stronger default for greenfield work. In the 2025 Stack Overflow Developer Survey (65,437 respondents), it reached 55.6% usage versus MySQL's 40.5% — the first time PostgreSQL has held a clear lead — and ranked first in most-admired and most-desired database for the third year running (Stack Overflow, 2025). MySQL remains dominant in legacy web stacks and shared hosting environments, but for new work PostgreSQL's feature set is now broadly preferred.

What tables does a basic e-commerce schema need?

A minimal schema needs five tables: categories, products, customers, orders, and order_items. The order_items table is the critical one — it links each order line to a product and stores the price at time of purchase so future price changes don't alter historical totals.

How do you model a many-to-many relationship?

Use a junction table. For posts and tags: create a post_tags table with foreign keys to both posts and tags. Set the primary key as a composite of both foreign keys — this prevents duplicate tag associations and creates a covering index automatically. Add ON DELETE CASCADE on both FKs so cleanup is handled at the database level.

Should I store product price in order_items or look it up from products?

Store it in order_items as price_at_purchase. If you look it up from products, any future price change retroactively alters every historical order total that includes that product. That's almost never the right behavior — and it can silently break financial reports.

What is a soft delete and when should I use it?

A soft delete adds a nullable deleted_at timestamp. Instead of removing the row you set deleted_at = NOW(). It's useful for audit trails and when a hard delete would break referential integrity. The ongoing cost: every query needs WHERE deleted_at IS NULL. Missing that filter in even one place exposes deleted rows as if they were active.

What is the difference between TIMESTAMP and TIMESTAMPTZ in PostgreSQL?

TIMESTAMPTZ stores the value in UTC internally and converts it to the session's configured timezone on retrieval. TIMESTAMP stores the literal value with no timezone information. For created_at, updated_at, and any audit column, always use TIMESTAMPTZ — consistent UTC storage avoids DST gaps and ordering bugs when users span multiple timezones.