Who this module is for: You have completed all of Phase 1. You can create tables, query, filter, aggregate, join, and enforce constraints. Now you need to put it all together: design a schema for a real application, connect to it from application code, and understand the tools that keep schemas manageable as they evolve. This module closes the Foundation phase and prepares you for the Practitioner topics ahead.
-- Users of the application CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, username TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, -- never store plain passwords display_name TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_login_at TIMESTAMPTZ );
-- Projects group tasks together CREATE TABLE projects ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, description TEXT, owner_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, -- Restrict: can't delete a user who owns projects created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), archived_at TIMESTAMPTZ -- NULL = active, non-NULL = archived );
-- Many-to-many: project membership (who can see and work in a project) CREATE TABLE project_members ( project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member', 'viewer')), joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (project_id, user_id) -- a user can only be in a project once );
-- Tasks are the work items CREATE TABLE tasks ( id BIGSERIAL PRIMARY KEY, project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, -- If a project is deleted, all its tasks are deleted too created_by BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, assigned_to BIGINT REFERENCES users(id) ON DELETE SET NULL, -- assigned_to is optional; if the assignee is deleted, set to NULL title TEXT NOT NULL, description TEXT, status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'in_progress', 'done', 'cancelled')), priority INTEGER NOT NULL DEFAULT 2 CHECK (priority BETWEEN 1 AND 5), -- 1=lowest, 5=highest due_date DATE, completed_at TIMESTAMPTZ, -- NULL until task is marked done created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );
-- Comments on tasks CREATE TABLE comments ( id BIGSERIAL PRIMARY KEY, task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, author_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, content TEXT NOT NULL CHECK (LENGTH(TRIM(content)) > 0), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), edited_at TIMESTAMPTZ -- NULL until edited );
-- Check the structure of each table \d users \d projects \d tasks \d comments \d project_members
-- See all foreign key relationships SELECT tc.table_name, kcu.column_name, ccu.table_name AS foreign_table, ccu.column_name AS foreign_column FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name WHERE tc.constraint_type = 'FOREIGN KEY' ORDER BY tc.table_name;
-- Add a project INSERT INTO projects (name, description, owner_id) VALUES ('Website Redesign', 'Redesign the company website', 1); -- Project id = 1
-- Add project members INSERT INTO project_members (project_id, user_id, role) VALUES (1, 1, 'owner'), -- Alice is owner (1, 2, 'member'), -- Bob is a member (1, 3, 'viewer'); -- Carol can view
-- All tasks in the project with assignee names SELECT t.title, t.status, t.priority, u.display_name AS assigned_to FROM tasks t LEFT JOIN users u ON t.assigned_to = u.id WHERE t.project_id = 1 ORDER BY t.priority DESC, t.created_at;
-- Project members and their roles SELECT u.display_name, u.email, pm.role, pm.joined_at FROM project_members pm JOIN users u ON pm.user_id = u.id WHERE pm.project_id = 1;
-- Tasks with comment count SELECT t.title, t.status, COUNT(c.id) AS comment_count FROM tasks t LEFT JOIN comments c ON t.id = c.task_id WHERE t.project_id = 1 GROUP BY t.id, t.title, t.status ORDER BY t.created_at;
-- Summary: tasks per status in this project SELECT status, COUNT(*) AS count FROM tasks WHERE project_id = 1 GROUP BY status;
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, // Or explicitly: // host: 'localhost', // port: 5432, // database: 'taskmanager', // user: 'postgres', // password: 'yourpassword', max: 10, // connection pool size idleTimeoutMillis: 30000, });
export async function query(text, params) { const result = await pool.query(text, params); return result; }
// Using it in your application import { query } from './db.js';
// Get all tasks for a project const result = await query( 'SELECT * FROM tasks WHERE project_id = $1 ORDER BY created_at', [projectId] ); const tasks = result.rows;
Never build SQL by string concatenation with user input:
// ❌ SQL INJECTION VULNERABILITY — never do this const result = await query( `SELECT * FROM users WHERE email = '${userInputEmail}'` ); // An attacker can input: ' OR '1'='1 and read all users
// ✅ ALWAYS use parameterised queries const result = await query( 'SELECT * FROM users WHERE email = $1', [userInputEmail] ); // $1 is a placeholder — user input is always treated as data, never as SQL
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: # Parameterised query with %s placeholders cur.execute( "SELECT * FROM tasks WHERE project_id = %s ORDER BY created_at", (project_id,) ) tasks = cur.fetchall() # list of dict-like rows
Schemas change. You add columns, rename things, add constraints. In development, you can DROP DATABASE and start over. In production, you cannot — there is live data there.
Schema migrations are versioned SQL scripts that transform the schema from one state to another.
-- migrations/001_initial_schema.sql -- Create the initial schema (the tables we defined above)
-- migrations/002_add_task_labels.sql CREATE TABLE labels ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, color TEXT NOT NULL DEFAULT '#888888' );
CREATE TABLE task_labels ( task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, label_id BIGINT NOT NULL REFERENCES labels(id) ON DELETE CASCADE, PRIMARY KEY (task_id, label_id) );
-- migrations/003_add_task_position.sql ALTER TABLE tasks ADD COLUMN position INTEGER NOT NULL DEFAULT 0; CREATE INDEX idx_tasks_project_position ON tasks (project_id, position);
✅ Install PostgreSQL and use psql confidently
✅ Design a schema from a real-world problem
✅ Use correct data types for every situation
✅ Write every fundamental SQL operation
✅ Filter, sort, and aggregate data
✅ Join data across multiple tables
✅ Enforce data integrity with constraints
✅ Connect from application code safely (parameterised queries)
✅ Understand schema migrations conceptually
This is the foundation that every PostgreSQL engineer builds on. Phase 2 — The Practitioner takes you from isolated scripts to real production application patterns: advanced SQL, indexing, transactions, JSON, full-text search, access control, and deployment.
Identify entities, attributes, and relationships before writing SQL
Junction tables
Model many-to-many relationships with a third table
Consistent timestamps
created_at, updated_at, soft-delete with archived_at / deleted_at
Parameterised queries
Always use $1, $2 placeholders — never string concatenation with user input
Schema migrations
Version your schema changes; never manually ALTER in production without a migration
pg_dump
Backup before any structural change
Phase 1 is complete. Phase 2 — The Practitioner — begins with Module P-1: Advanced SQL, covering CTEs, window functions, upserts, and the patterns every production engineer uses weekly.
Next: P-1 — Advanced SQL — The Patterns You Will Use Every Week →
Knowledge Check
A Senior Engineer is reviewing the taskmanager schema's foreign key ON DELETE actions. If a users record is attempted to be deleted, which of the following statements accurately describes the *immediate* production behavior based on the provided schema?
A junior developer implements a user search feature using string concatenation for SQL queries, similar to the ❌ SQL INJECTION VULNERABILITY example. During a security audit, it's identified that an attacker could input ' OR '1'='1 into the search field. What is the most critical implication of this vulnerability in a production environment?
A team needs to add a new priority_level column to the tasks table in a production environment. They are debating between manually running ALTER TABLE commands directly on the production database or using a schema migration tool like node-pg-migrate. As a Senior Principal Software Engineer, what is the primary reason to strongly advocate for using a migration tool?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.