Mastering Postgres: From Schema Design to Production Queries

A comprehensive guide to relational databases and PostgreSQL for backend engineers. Covers why databases exist, DBMS responsibilities, relational vs non-relational trade-offs, schema design with migrations, data types, relationships (one-to-one, one-to-many, many-to-many), indexing strategy, triggers for automation, and practical SQL query patterns for common API operations.

Why Databases Matter

Persistence: The Core Purpose

Databases store data in a way that survives after the program stops running. Without persistence, a to-do app would lose all tasks every time it closes. Persistence means data remains in the same state across time and physical locations.

What Counts as a Database

Any structured storage system offering create, read, update, and delete (CRUD) operations qualifies as a database. Examples range from smartphone contact lists and browser local storage to text files and enterprise relational systems.

Why Disk Storage Over RAM

Disk-based databases (HDD/SSD) are chosen over RAM because storage is cheap and abundant. Most systems have 8–128 GB RAM but 512 GB–2 TB disk storage. The trade-off: disk is slower but offers vastly more capacity for the cost.

DBMS: The Software Layer

What a DBMS Does

A database management system (DBMS) is software that efficiently handles data organization, CRUD operations, data integrity, and security. It abstracts away the complexity of disk storage so applications don't have to manage raw files.

Problems with Plain Text Files

Storing data in text files creates four major issues: slow parsing (especially in interpreted languages), no enforced structure, no concurrency control, and high error risk. These limitations led to the invention of DBMS software.

Relational vs Non-Relational

Relational Databases: Structure and Integrity

Relational databases organize data in tables with predefined schemas. All rows in a table must follow the same structure. This enforces data integrity but requires upfront schema planning. Examples: PostgreSQL, MySQL, SQL Server.

Non-Relational Databases: Flexibility

Non-relational (NoSQL) databases like MongoDB allow flexible schemas. Each document can have different fields. This enables rapid prototyping without schema design but sacrifices data consistency and requires application-level validation.

Use Case: CRM vs CMS

CRM (customer relationship management) systems benefit from relational databases because they need strict data consistency and complex relationships. CMS (content management systems) suit NoSQL because content structure varies (images, code blocks, embeds).

Why PostgreSQL Wins

PostgreSQL is open-source, SQL-compliant (easy migration), highly extensible, reliable, scalable, and has excellent JSON support. It eliminates the need to switch to NoSQL just for flexible data, making it the default choice for most projects.

Data Types in PostgreSQL

Integer Types: serial, integer, bigint

Serial auto-increments with each row; use bigserial in production. Integer types scale from small (16-bit) to big (64-bit). Choose based on expected value range.

Decimal vs Float: Accuracy vs Speed

Use decimal/numeric for money and critical calculations (e.g., prices) because they store exact values. Use float/double for scientific data where small rounding errors are acceptable and speed matters.

String Types: char, varchar, text

Avoid char (pads with spaces). Use varchar(n) with caution (arbitrary limits like 255 are MySQL conventions). Prefer text: it's equally fast, avoids migrations when length needs change, and is self-documenting.

Boolean, Date, Time, Timestamp

Boolean stores true/false. Date stores only the date. Time stores hour:minute:second. Timestamp stores date and time. Timestamp with time zone includes timezone info for distributed systems.

UUID: Distributed Primary Keys

UUID (universally unique identifier) is a native PostgreSQL type ideal for primary keys in distributed systems. Unlike auto-incrementing integers, UUIDs are globally unique without coordination.

JSON vs JSONB

JSON stores text; JSONB stores binary (serialized) format. JSONB is faster for queries and indexing. Always use JSONB for production unless you need to preserve JSON formatting.

Array Types

PostgreSQL supports arrays of any data type: integer[], text[], jsonb[], etc. Useful for storing collections without a separate table, though normalization is often preferred.

Database Migrations: Version Control for Schema

What Are Migrations

Migrations are SQL files that track schema changes over time. Each file contains up (apply) and down (revert) migrations. Tools like dbmate execute them sequentially, maintaining a schema_migrations table to track the current version.

Up and Down Migrations

Up migrations apply changes (create table, add index). Down migrations revert them (drop table, drop index). Down migrations enable rollback if something breaks in production.

Why Migrations Matter

Migrations provide version control for your database schema, enable rollback capability, document schema evolution, and prevent accidental manual changes. They live in your Git repository alongside code.

Schema Design: Project Management Platform

Enum Types: Constrained Values

Enums enforce a fixed set of allowed values at the database level (e.g., project_status can only be 'active', 'completed', or 'archived'). This provides data integrity and serves as documentation for future developers.

Primary Keys and Defaults

Every table should have a primary key (usually ID). Use UUID with default generate_random_uuid() for distributed systems. Primary keys are automatically indexed and enforce uniqueness and not-null constraints.

Not Null Constraint

Apply not null to ~70% of fields to maintain data consistency. Without it, buggy scripts can insert null values, corrupting your database. Only omit not null when a field is genuinely optional.

Unique Constraint

Unique constraints ensure a field value appears only once in the table (e.g., email addresses). Attempting to insert a duplicate value fails at the database level, preventing data corruption.

Metadata Fields: created_at, updated_at

Every table should have created_at (set once at insertion) and updated_at (updated on every modification). These enable sorting by recency and tracking data changes over time.

One-to-One Relationship: User and Profile

One-to-one relationships separate concerns (e.g., user authentication in users table, profile customization in user_profiles table). Implement by making the foreign key also the primary key in the related table.

One-to-Many Relationship: Project and Tasks

One project has many tasks. Implement by adding a foreign key (project_id) in the child table (tasks). The foreign key references the parent's primary key (projects.id).

Many-to-Many Relationship: Users and Projects

A user belongs to many projects; a project has many users. Implement with a linking table (project_members) containing foreign keys to both tables. Use a composite primary key (project_id, user_id) to prevent duplicates.

Referential Integrity: ON DELETE Constraints

ON DELETE RESTRICT prevents deletion if child records exist (e.g., can't delete a user with projects). ON DELETE CASCADE deletes child records automatically. ON DELETE SET NULL sets the foreign key to null. Choose based on business logic.

Check Constraint: Enforce Rules

Check constraints validate field values at the database level (e.g., priority BETWEEN 1 AND 5). This prevents invalid data from entering the database without relying on application code.

Indexing: Query Performance

What Is an Index

An index is a lookup table mapping field values to row locations on disk. Without an index, the database must scan every row sequentially (slow). With an index, it can jump directly to matching rows (fast).

When to Index: Join, Where, Order By

Index fields used in join conditions, where clauses, or order by clauses. For example, if you frequently query 'tasks WHERE status = pending', index the status field. If you sort by created_at descending, create a descending index.

Index Trade-offs

Indexes speed up reads but slow down writes (inserts/updates) because the index must be maintained. Evaluate whether the query frequency justifies the maintenance overhead. Monitor performance and remove unused indexes.

Composite and Descending Indexes

Composite indexes combine multiple fields (e.g., (project_id, status)) for multi-field queries. Descending indexes optimize order by descending queries. Choose index order based on your most common query patterns.

Triggers: Automation at the Database Level

What Triggers Do

Triggers are functions that execute automatically when a database event occurs (insert, update, delete). Use them to automate repetitive tasks like updating the updated_at field on every row modification.

Updating updated_at Automatically

Create a trigger function that sets updated_at to the current timestamp. Attach it to all tables with an update trigger. This eliminates the need to manually set updated_at in application code.

Triggers vs Application Logic

Triggers enforce rules at the database level, ensuring consistency even if application code has bugs. However, they add complexity and are harder to test. Use sparingly for critical invariants like timestamp updates.

Query Patterns for Common APIs

GET /users: Fetch All with Joins

Join users with user_profiles to embed profile data in each user object. Use left join to include users without profiles. Return results as JSON with row_to_jsonb() to nest related data.

GET /users/:id: Fetch Single with Parameters

Use parameterized queries to safely pass the user ID. Prevents SQL injection. Return the same nested structure as the list endpoint.

POST /users: Insert and Return

Use INSERT with RETURNING * to get the newly created row. Pass email, full_name, and password_hash as parameterized values. Database generates ID and timestamps automatically.

PATCH /users/:id/profile: Partial Update

Update only the fields the user provided. Use UPDATE with SET for each field. Include WHERE to target the specific user. Use RETURNING * to confirm the update.

Dynamic Filtering: WHERE with ILIKE

Support optional filters like 'first letter of name'. Use WHERE full_name ILIKE $1 || '%' to match names starting with a letter. Omit the WHERE clause if the filter isn't provided.

Dynamic Sorting: ORDER BY with Parameters

Allow users to sort by different fields (email, full_name, created_at) and order (ASC/DESC). Construct the ORDER BY clause dynamically in application code, then pass to the query.

Pagination: LIMIT and OFFSET

Use OFFSET (page - 1) * limit and LIMIT to paginate results. Example: page 2 with limit 10 uses OFFSET 10 LIMIT 10. Always include pagination in list endpoints to avoid returning millions of rows.

Parameterized Queries: SQL Injection Prevention

Never concatenate user input into SQL strings. Always use parameterized queries with placeholders ($1, $2, etc.). The database treats parameters as data, not executable code, preventing injection attacks.

Best Practices and Conventions

Naming Conventions: snake_case and Plural

Use lowercase snake_case for all table and column names (users, user_profiles, created_at). Use plural for table names (users, not user). Avoid camelCase to prevent needing quotes in queries.

Data Seeding for Development

Create separate migration files to insert test data into your database. Use CTEs (common table expressions) to make seed queries readable. Seed data enables local testing without hitting production.

Separating Concerns with Tables

Split related data into separate tables (e.g., users and user_profiles) to allow independent scaling and modification. This prevents cascading migrations when one concern changes.

Enforcing Constraints at the Database Level

Use not null, unique, check, and foreign key constraints to catch invalid data at the database, not the application. This ensures consistency even if application code has bugs or is bypassed.

Notable quotes

Persistence means storing data so it survives after the program that created it stops. — Sriniously
Always go with text instead of varchar with arbitrary lengths, because it's self-documenting and avoids future migrations. — Sriniously
Parameterized queries prevent SQL injection by treating user input as data, never as executable code. — Sriniously

Action items

  • Learn SQL basics (select, insert, update, delete, joins, group by, order by) from a dedicated SQL tutorial before proceeding with advanced patterns
  • Set up PostgreSQL locally and install a GUI tool like TablePlus or DBeaver for interactive query writing and exploration
  • Design your first database schema by identifying entities (users, projects, tasks) and relationships (one-to-one, one-to-many, many-to-many)
  • Create migration files for your schema using a tool like dbmate; write both up and down migrations for every change
  • Seed your database with test data using a separate migration file to enable local development and testing
  • Write parameterized queries for all CRUD operations to prevent SQL injection; never concatenate user input into SQL strings
  • Identify fields used in join conditions, where clauses, and order by clauses; create indexes on those fields to optimize query performance
  • Add created_at and updated_at fields to all tables; use a trigger to automatically update updated_at on every row modification
  • Test your queries in a SQL editor before integrating them into your backend code; verify they return the expected data structure
  • Monitor query performance in production; remove unused indexes and add new ones based on slow query logs
Sriniously
2 hr 45 min video
4 min read
Mastering Postgres: From Schema Design to Production Queries
You just saved 2 hr 41 min.
The big takeaway
A comprehensive guide to relational databases and PostgreSQL for backend engineers. Covers why databases exist, DBMS responsibilities, relational vs non-relational trade-offs, schema design with migrations, data types, relationships (one-to-one, one-to-many, many-to-many), indexing strategy, triggers for automation, and practical SQL query patterns for common API operations.
Why Databases Matter
Persistence: The Core Purpose
Databases store data in a way that survives after the program stops running. Without persistence, a to-do app would lose all tasks every time it closes. Persistence means data remains in the same state across time and physical locations.
What Counts as a Database
Any structured storage system offering create, read, update, and delete (CRUD) operations qualifies as a database. Examples range from smartphone contact lists and browser local storage to text files and enterprise relational systems.
Why Disk Storage Over RAM
Disk-based databases (HDD/SSD) are chosen over RAM because storage is cheap and abundant. Most systems have 8–128 GB RAM but 512 GB–2 TB disk storage. The trade-off: disk is slower but offers vastly more capacity for the cost.
Typical RAM
32 GB
Typical Disk Storage
1024 GB
Storage capacity comparison: disk offers ~30x more space than RAM
DBMS: The Software Layer
What a DBMS Does
A database management system (DBMS) is software that efficiently handles data organization, CRUD operations, data integrity, and security. It abstracts away the complexity of disk storage so applications don't have to manage raw files.
Problems with Plain Text Files
Storing data in text files creates four major issues: slow parsing (especially in interpreted languages), no enforced structure, no concurrency control, and high error risk. These limitations led to the invention of DBMS software.
1
Parsing overhead
High
2
No schema enforcement
High
3
Concurrency conflicts
High
4
Data corruption risk
High
Why text files fail for production data
Relational vs Non-Relational
Relational Databases: Structure and Integrity
Relational databases organize data in tables with predefined schemas. All rows in a table must follow the same structure. This enforces data integrity but requires upfront schema planning. Examples: PostgreSQL, MySQL, SQL Server.
Non-Relational Databases: Flexibility
Non-relational (NoSQL) databases like MongoDB allow flexible schemas. Each document can have different fields. This enables rapid prototyping without schema design but sacrifices data consistency and requires application-level validation.
Use Case: CRM vs CMS
CRM (customer relationship management) systems benefit from relational databases because they need strict data consistency and complex relationships. CMS (content management systems) suit NoSQL because content structure varies (images, code blocks, embeds).
Why PostgreSQL Wins
PostgreSQL is open-source, SQL-compliant (easy migration), highly extensible, reliable, scalable, and has excellent JSON support. It eliminates the need to switch to NoSQL just for flexible data, making it the default choice for most projects.
1
Open-source and free
2
SQL standard compliant
3
Highly extensible
4
Native JSON support
5
Proven reliability
Five reasons PostgreSQL is the default choice
Data Types in PostgreSQL
Integer Types: serial, integer, bigint
Serial auto-increments with each row; use bigserial in production. Integer types scale from small (16-bit) to big (64-bit). Choose based on expected value range.
Decimal vs Float: Accuracy vs Speed
Use decimal/numeric for money and critical calculations (e.g., prices) because they store exact values. Use float/double for scientific data where small rounding errors are acceptable and speed matters.
Float (fast, approximate)
3.14159265358979...
Decimal (exact, slower)
3.14 (exact)
Precision trade-off: floats are faster but inexact
String Types: char, varchar, text
Avoid char (pads with spaces). Use varchar(n) with caution (arbitrary limits like 255 are MySQL conventions). Prefer text: it's equally fast, avoids migrations when length needs change, and is self-documenting.
Boolean, Date, Time, Timestamp
Boolean stores true/false. Date stores only the date. Time stores hour:minute:second. Timestamp stores date and time. Timestamp with time zone includes timezone info for distributed systems.
UUID: Distributed Primary Keys
UUID (universally unique identifier) is a native PostgreSQL type ideal for primary keys in distributed systems. Unlike auto-incrementing integers, UUIDs are globally unique without coordination.
JSON vs JSONB
JSON stores text; JSONB stores binary (serialized) format. JSONB is faster for queries and indexing. Always use JSONB for production unless you need to preserve JSON formatting.
Array Types
PostgreSQL supports arrays of any data type: integer[], text[], jsonb[], etc. Useful for storing collections without a separate table, though normalization is often preferred.
Database Migrations: Version Control for Schema
What Are Migrations
Migrations are SQL files that track schema changes over time. Each file contains up (apply) and down (revert) migrations. Tools like dbmate execute them sequentially, maintaining a schema_migrations table to track the current version.
1
Create users table
2
Create projects table
3
Add indexes
4
Create triggers
Migrations execute sequentially, tracked in schema_migrations
Up and Down Migrations
Up migrations apply changes (create table, add index). Down migrations revert them (drop table, drop index). Down migrations enable rollback if something breaks in production.
Why Migrations Matter
Migrations provide version control for your database schema, enable rollback capability, document schema evolution, and prevent accidental manual changes. They live in your Git repository alongside code.
Schema Design: Project Management Platform
Enum Types: Constrained Values
Enums enforce a fixed set of allowed values at the database level (e.g., project_status can only be 'active', 'completed', or 'archived'). This provides data integrity and serves as documentation for future developers.
Primary Keys and Defaults
Every table should have a primary key (usually ID). Use UUID with default generate_random_uuid() for distributed systems. Primary keys are automatically indexed and enforce uniqueness and not-null constraints.
Not Null Constraint
Apply not null to ~70% of fields to maintain data consistency. Without it, buggy scripts can insert null values, corrupting your database. Only omit not null when a field is genuinely optional.
Unique Constraint
Unique constraints ensure a field value appears only once in the table (e.g., email addresses). Attempting to insert a duplicate value fails at the database level, preventing data corruption.
Metadata Fields: created_at, updated_at
Every table should have created_at (set once at insertion) and updated_at (updated on every modification). These enable sorting by recency and tracking data changes over time.
One-to-One Relationship: User and Profile
One-to-one relationships separate concerns (e.g., user authentication in users table, profile customization in user_profiles table). Implement by making the foreign key also the primary key in the related table.
One-to-Many Relationship: Project and Tasks
One project has many tasks. Implement by adding a foreign key (project_id) in the child table (tasks). The foreign key references the parent's primary key (projects.id).
Many-to-Many Relationship: Users and Projects
A user belongs to many projects; a project has many users. Implement with a linking table (project_members) containing foreign keys to both tables. Use a composite primary key (project_id, user_id) to prevent duplicates.
Referential Integrity: ON DELETE Constraints
ON DELETE RESTRICT prevents deletion if child records exist (e.g., can't delete a user with projects). ON DELETE CASCADE deletes child records automatically. ON DELETE SET NULL sets the foreign key to null. Choose based on business logic.
Check Constraint: Enforce Rules
Check constraints validate field values at the database level (e.g., priority BETWEEN 1 AND 5). This prevents invalid data from entering the database without relying on application code.
Indexing: Query Performance
What Is an Index
An index is a lookup table mapping field values to row locations on disk. Without an index, the database must scan every row sequentially (slow). With an index, it can jump directly to matching rows (fast).
When to Index: Join, Where, Order By
Index fields used in join conditions, where clauses, or order by clauses. For example, if you frequently query 'tasks WHERE status = pending', index the status field. If you sort by created_at descending, create a descending index.
Index Trade-offs
Indexes speed up reads but slow down writes (inserts/updates) because the index must be maintained. Evaluate whether the query frequency justifies the maintenance overhead. Monitor performance and remove unused indexes.
Composite and Descending Indexes
Composite indexes combine multiple fields (e.g., (project_id, status)) for multi-field queries. Descending indexes optimize order by descending queries. Choose index order based on your most common query patterns.
Triggers: Automation at the Database Level
What Triggers Do
Triggers are functions that execute automatically when a database event occurs (insert, update, delete). Use them to automate repetitive tasks like updating the updated_at field on every row modification.
Updating updated_at Automatically
Create a trigger function that sets updated_at to the current timestamp. Attach it to all tables with an update trigger. This eliminates the need to manually set updated_at in application code.
Triggers vs Application Logic
Triggers enforce rules at the database level, ensuring consistency even if application code has bugs. However, they add complexity and are harder to test. Use sparingly for critical invariants like timestamp updates.
Query Patterns for Common APIs
GET /users: Fetch All with Joins
Join users with user_profiles to embed profile data in each user object. Use left join to include users without profiles. Return results as JSON with row_to_jsonb() to nest related data.
1
SELECT u.*, row_to_jsonb(up.*) AS profile
2
FROM users u
3
LEFT JOIN user_profiles up ON u.id = up.user_id
4
ORDER BY u.created_at DESC
Fetch all users with nested profile data in one query
GET /users/:id: Fetch Single with Parameters
Use parameterized queries to safely pass the user ID. Prevents SQL injection. Return the same nested structure as the list endpoint.
POST /users: Insert and Return
Use INSERT with RETURNING * to get the newly created row. Pass email, full_name, and password_hash as parameterized values. Database generates ID and timestamps automatically.
PATCH /users/:id/profile: Partial Update
Update only the fields the user provided. Use UPDATE with SET for each field. Include WHERE to target the specific user. Use RETURNING * to confirm the update.
Dynamic Filtering: WHERE with ILIKE
Support optional filters like 'first letter of name'. Use WHERE full_name ILIKE $1 || '%' to match names starting with a letter. Omit the WHERE clause if the filter isn't provided.
Dynamic Sorting: ORDER BY with Parameters
Allow users to sort by different fields (email, full_name, created_at) and order (ASC/DESC). Construct the ORDER BY clause dynamically in application code, then pass to the query.
Pagination: LIMIT and OFFSET
Use OFFSET (page - 1) * limit and LIMIT to paginate results. Example: page 2 with limit 10 uses OFFSET 10 LIMIT 10. Always include pagination in list endpoints to avoid returning millions of rows.
Parameterized Queries: SQL Injection Prevention
Never concatenate user input into SQL strings. Always use parameterized queries with placeholders ($1, $2, etc.). The database treats parameters as data, not executable code, preventing injection attacks.
Best Practices and Conventions
Naming Conventions: snake_case and Plural
Use lowercase snake_case for all table and column names (users, user_profiles, created_at). Use plural for table names (users, not user). Avoid camelCase to prevent needing quotes in queries.
Data Seeding for Development
Create separate migration files to insert test data into your database. Use CTEs (common table expressions) to make seed queries readable. Seed data enables local testing without hitting production.
Separating Concerns with Tables
Split related data into separate tables (e.g., users and user_profiles) to allow independent scaling and modification. This prevents cascading migrations when one concern changes.
Enforcing Constraints at the Database Level
Use not null, unique, check, and foreign key constraints to catch invalid data at the database, not the application. This ensures consistency even if application code has bugs or is bypassed.
Worth quoting
"Persistence means storing data so it survives after the program that created it stops."
— Sriniously, at [0:32]
"Always go with text instead of varchar with arbitrary lengths, because it's self-documenting and avoids future migrations."
— Sriniously, at [45:04]
"Parameterized queries prevent SQL injection by treating user input as data, never as executable code."
— Sriniously, at [113:54]
Try this
Learn SQL basics (select, insert, update, delete, joins, group by, order by) from a dedicated SQL tutorial before proceeding with advanced patterns
Set up PostgreSQL locally and install a GUI tool like TablePlus or DBeaver for interactive query writing and exploration
Design your first database schema by identifying entities (users, projects, tasks) and relationships (one-to-one, one-to-many, many-to-many)
Create migration files for your schema using a tool like dbmate; write both up and down migrations for every change
Seed your database with test data using a separate migration file to enable local development and testing
Write parameterized queries for all CRUD operations to prevent SQL injection; never concatenate user input into SQL strings
Identify fields used in join conditions, where clauses, and order by clauses; create indexes on those fields to optimize query performance
Add created_at and updated_at fields to all tables; use a trigger to automatically update updated_at on every row modification
Test your queries in a SQL editor before integrating them into your backend code; verify they return the expected data structure
Monitor query performance in production; remove unused indexes and add new ones based on slow query logs
Made with Glimpse by Wozart
glimpse.wozart.com/v/lz3watnx
Share this infographic

More like this