Master PostgreSQL: Database Design Case Study (Banking System Schema)
This comprehensive technical guide explores advanced PostgreSQL mechanics, database schema design fundamentals, and architectural implementation through a real-world Banking System Case Study. It covers everything from SQL conditional branching (CASE), data manipulation language (DML) migrations via ALTER TABLE, and relational integrity rules to procedural logic via PL/pgSQL functions and stored procedures, concluding with an end-to-end normalized schema design for enterprise banking.
1. Advanced Querying: Conditional Logic with CASE Expressions
PostgreSQL provides procedural conditional evaluation directly inside SQL statements using the CASE expression. It acts as an if-else or switch-case construct to compute dynamic outputs based on table attributes.
Real-Life Analogy
Think of a
CASEexpression like an automated banking tier system at a customer service desk:
- If an account balance exceeds , the request is tagged as Platinum.
- If it is between and , it is routed as Gold.
- Everything else is processed as Standard. The physical customer record does not change in the database; rather, the dynamic evaluation determines the real-time classification label on the fly.
Dynamic Categorization Syntax
SELECT
activity_id,
user_id,
watch_percentage,
CASE
WHEN watch_percentage >= 90 THEN 'Completed'
WHEN watch_percentage >= 50 THEN 'Partially Watched'
WHEN watch_percentage >= 20 THEN 'Started'
ELSE 'Barely Watched'
END AS viewing_status
FROM viewer_activity;
Multi-Condition Evaluation with Logical Operators
Multiple logical predicates (AND, OR, IN, BETWEEN) can be combined within CASE branches to construct complex business rules:
SELECT
title,
rating,
content_rating,
CASE
WHEN rating >= 9.0 AND content_rating IN ('PG', 'PG-13') THEN 'Family-Friendly Masterpiece'
WHEN rating >= 8.5 THEN 'Must Watch'
WHEN rating >= 7.0 THEN 'Good Watch'
WHEN rating >= 5.0 THEN 'Average'
ELSE 'Skip'
END AS recommendation_tier,
CASE
WHEN release_year >= 2024 THEN 'New Release'
WHEN release_year >= 2020 THEN 'Recent'
ELSE 'Classic / Catalog'
END AS recency_classification
FROM platform_movies;
Note on DDL vs. Dynamic Queries: While
CASEexpressions are powerful withinSELECT,WHERE, andORDER BYclauses, they should not replace declarative database constraints (CHECKconstraints) during table definition. Table constraints enforce data invariants at write time, whereasCASEhandles data projection at read time.
2. Table Mutations & Schema Evolution via ALTER TABLE
In enterprise production environments, database schemas evolve continuously. The ALTER TABLE command allows engineers to modify existing table definitions without data loss.
Existing Table Structure (Columns A, B)
|
| ALTER TABLE ... ADD COLUMN
v
Modified Structure (Columns A, B, C [populated as NULL or DEFAULT])
|
| ALTER TABLE ... DROP COLUMN
v
Final Table Structure (Columns A, C)
Core Schema Modification Operations
-- 1. Adding a Column with constraints and defaults
ALTER TABLE accounts
ADD COLUMN account_status VARCHAR(20) DEFAULT 'ACTIVE' NOT NULL;
-- 2. Dropping a Column safely
ALTER TABLE accounts
DROP COLUMN IF EXISTS legacy_routing_code;
-- 3. Renaming a Column
ALTER TABLE accounts
RENAME COLUMN balance TO current_balance;
-- 4. Modifying Data Type with Explicit Conversion
ALTER TABLE accounts
ALTER COLUMN current_balance TYPE NUMERIC(15, 2);
-- 5. Adding Foreign Key Constraint
ALTER TABLE accounts
ADD CONSTRAINT fk_accounts_customer
FOREIGN KEY (customer_id)
REFERENCES customers(customer_id)
ON DELETE RESTRICT;
Comparison: Table Structural Operations
3. Relational Architecture: Keys, Constraints & Cascades
Relational integrity guarantees that relationships between database tables remain strictly consistent throughout inserts, updates, and deletions.
1. Primary Keys (PK)
A column (or set of columns) that uniquely identifies each row in a table.
- Rules: Must be strictly
UNIQUEandNOT NULL. A table can have only one Primary Key. - Composite Primary Keys: Formed when two or more columns together uniquely identify a record (common in junction tables).
2. Foreign Keys (FK)
A column that establishes a direct link between the data in two tables by referencing the Primary Key (or a Unique Key) of another table.
- Rules: FK values must match an existing PK in the parent table or be explicitly
NULL. Prevents orphaned records.
3. Referential Actions on Delete / Update (CASCADE)
When a referenced record in a parent table is modified or deleted, referential actions dictate child table behavior:
ON DELETE CASCADE: When the parent record is deleted, automatically delete all child records referencing it.ON DELETE RESTRICT/NO ACTION: Prevent deletion of the parent record if any child record references it.ON DELETE SET NULL: When the parent record is deleted, set the child's foreign key column value toNULL.ON DELETE SET DEFAULT: Set the foreign key column to its configured default value.
4. Complete Taxonomy of SQL Joins
SQL Joins merge columns from one or more tables based on a logical relationship between shared attributes.
Join Types Breakdown
A. INNER JOIN
Returns only records where matching join keys exist in both the left and right tables.
SELECT
c.customer_id,
c.first_name,
a.account_number,
a.balance
FROM customers c
INNER JOIN accounts a ON c.customer_id = a.customer_id;
B. LEFT JOIN (Left Outer Join)
Returns all records from the left table and matched records from the right table. Unmatched right-side columns are filled with NULL.
SELECT
c.customer_id,
c.first_name,
l.loan_id,
l.loan_amount
FROM customers c
LEFT JOIN loans l ON c.customer_id = l.customer_id;
C. RIGHT JOIN (Right Outer Join)
Returns all records from the right table and matched records from the left table. Unmatched left-side columns are filled with NULL. (In practice, developers favor converting queries to LEFT JOIN for read clarity).
D. FULL OUTER JOIN
Combines results of both Left and Right joins. Returns all rows from both tables; where no match exists, NULL values fill the opposing side.
SELECT
s.student_name,
c.class_name
FROM students s
FULL OUTER JOIN classes c ON s.class_id = c.class_id;
E. CROSS JOIN (Cartesian Product)
Pairs every single row from Table A with every single row from Table B. If Table A has rows and Table B has rows, the result contains rows. Does not use an ON clause.
SELECT
b.branch_name,
at.type_name
FROM branches b
CROSS JOIN account_types at;
F. SELF JOIN
A table joined with itself to model hierarchical data (e.g., employee-manager hierarchies, parent-child product categories).
SELECT
e.employee_name AS employee,
m.employee_name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.employee_id;
5. Aggregation, Filtering & Database Views
Grouping aggregates row-level data into summary metrics. PostgreSQL handles aggregate filtering via distinct pipeline stages.
WHERE vs. HAVING Execution Pipeline
WHERE: Filters base rows before aggregation occurs. Cannot evaluate aggregate functions (COUNT(),SUM(),AVG()).HAVING: Filters grouped summary rows after theGROUP BYclause evaluates aggregations.
SELECT
branch_id,
COUNT(account_id) AS total_accounts,
SUM(balance) AS total_branch_liquidity
FROM accounts
WHERE account_status = 'ACTIVE' -- Row-level filter
GROUP BY branch_id
HAVING SUM(balance) > 1000000.00; -- Aggregate filter
Encapsulating Complex Queries with Database Views
A VIEW is a saved, named virtual query definition. It acts as an abstraction layer to simplify access patterns without storing duplicate data physically on disk.
-- Create an abstracted reporting view
CREATE OR REPLACE VIEW view_high_value_branch_summary AS
SELECT
b.branch_id,
b.branch_name,
b.city,
COUNT(a.account_id) AS total_active_accounts,
COALESCE(SUM(a.balance), 0.00) AS total_liquidity
FROM branches b
LEFT JOIN accounts a ON b.branch_id = a.branch_id AND a.account_status = 'ACTIVE'
GROUP BY b.branch_id, b.branch_name, b.city;
-- Query the view like a regular table
SELECT * FROM view_high_value_branch_summary WHERE total_liquidity > 500000;
6. Procedural Architecture: PL/pgSQL Functions vs. Stored Procedures
PostgreSQL supports server-side procedural programming via PL/pgSQL (Procedural Language / PostgreSQL Structured Query Language). This moves execution logic inside the database engine, reducing network round-trips for high-throughput transactional flows.
Syntax Breakdown: User-Defined Functions (UDF)
A Function must always return a value (scalar, table, or void) and can be executed inline within SQL queries (SELECT my_function()).
CREATE OR REPLACE FUNCTION fn_calculate_interest(
p_balance NUMERIC,
p_interest_rate NUMERIC,
p_years INT
)
RETURNS NUMERIC
LANGUAGE plpgsql
AS
$$
DECLARE
v_total_amount NUMERIC;
BEGIN
v_total_amount := p_balance * (1 + (p_interest_rate / 100) * p_years);
RETURN ROUND(v_total_amount, 2);
END;
$$;
-- Executing the function
SELECT fn_calculate_interest(50000.00, 4.5, 3) AS projected_maturity_amount;
Syntax Breakdown: Stored Procedures
A Stored Procedure performs imperative actions and natively manages internal transactions (COMMIT, ROLLBACK). It is invoked using the CALL statement.
CREATE OR REPLACE PROCEDURE sp_execute_bank_transfer(
p_sender_account_id INT,
p_receiver_account_id INT,
p_transfer_amount NUMERIC
)
LANGUAGE plpgsql
AS
$$
DECLARE
v_sender_balance NUMERIC;
BEGIN
-- 1. Validate transfer amount
IF p_transfer_amount <= 0 THEN
RAISE EXCEPTION 'Transfer amount must be strictly positive.';
END IF;
-- 2. Fetch and lock sender record
SELECT balance INTO v_sender_balance
FROM accounts
WHERE account_id = p_sender_account_id
FOR UPDATE;
-- 3. Balance verification
IF v_sender_balance < p_transfer_amount THEN
RAISE EXCEPTION 'Insufficient funds: Available %, Required %', v_sender_balance, p_transfer_amount;
END IF;
-- 4. Execute atomic debit
UPDATE accounts
SET balance = balance - p_transfer_amount,
updated_at = CURRENT_TIMESTAMP
WHERE account_id = p_sender_account_id;
-- 5. Execute atomic credit
UPDATE accounts
SET balance = balance + p_transfer_amount,
updated_at = CURRENT_TIMESTAMP
WHERE account_id = p_receiver_account_id;
-- 6. Insert audit transaction logs
INSERT INTO transactions (account_id, transaction_type, amount, status)
VALUES (p_sender_account_id, 'DEBIT', p_transfer_amount, 'SUCCESS');
INSERT INTO transactions (account_id, transaction_type, amount, status)
VALUES (p_receiver_account_id, 'CREDIT', p_transfer_amount, 'SUCCESS');
END;
$$;
-- Invoking the procedure
CALL sp_execute_bank_transfer(1001, 1002, 7500.00);
Architectural Comparison: Functions vs. Procedures
7. Database Design Case Study: Core Banking System
System Requirements Analysis
A core enterprise banking system requires strict multi-entity normalization, relational integrity, zero-loss transaction logging, and strict data type definitions.
System Entity Invariants
- Branches: Physical/regional banking centers maintaining unique identifiers and IFSC routing codes.
- Customers: Identity records containing KYC identifiers (tax ID, unique email, phone).
- Accounts: Financial instruments owned by customers, linked to a home branch, carrying balances and statuses.
- Transactions: Append-only ledger logs recording all monetary mutations (Debit/Credit).
- Loans & Loan Payments: Debt facilities tracking total issued principal, interest rates, maturities, and discrete installment repayments.
8. Banking System Data Dictionary & Relational Model
Entity Relationships Breakdown
9. Production-Grade PostgreSQL Implementation (DDL Script)
Below is the complete, self-contained SQL script defining custom enumeration types, primary-foreign key relationships, operational check constraints, table structures, and performance indexes.
-- ============================================================================
-- 1. ENUMERATION TYPES (DOMAIN CONSTRAINTS)
-- ============================================================================
CREATE TYPE enum_account_type AS ENUM ('SAVINGS', 'CHECKING', 'CURRENT', 'SALARY');
CREATE TYPE enum_account_status AS ENUM ('ACTIVE', 'DORMANT', 'FROZEN', 'CLOSED');
CREATE TYPE enum_transaction_type AS ENUM ('DEBIT', 'CREDIT');
CREATE TYPE enum_transaction_status AS ENUM ('PENDING', 'SUCCESS', 'FAILED', 'REVERSED');
CREATE TYPE enum_loan_type AS ENUM ('HOME', 'PERSONAL', 'AUTO', 'EDUCATION', 'BUSINESS');
CREATE TYPE enum_loan_status AS ENUM ('APPLIED', 'APPROVED', 'DISBURSED', 'ACTIVE', 'DEFAULTED', 'CLOSED');
-- ============================================================================
-- 2. TABLE DEFINITIONS
-- ============================================================================
-- Table: branches
CREATE TABLE branches (
branch_id SERIAL PRIMARY KEY,
branch_name VARCHAR(100) NOT NULL,
branch_code VARCHAR(20) NOT NULL UNIQUE,
ifsc_code VARCHAR(11) NOT NULL UNIQUE,
city VARCHAR(50) NOT NULL,
state VARCHAR(50) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);
-- Table: customers
CREATE TABLE customers (
customer_id SERIAL PRIMARY KEY,
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
email VARCHAR(100) NOT NULL UNIQUE,
phone_number VARCHAR(20) NOT NULL UNIQUE,
date_of_birth DATE NOT NULL,
tax_id_number VARCHAR(50) NOT NULL UNIQUE,
address_line TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT chk_customer_age CHECK (date_of_birth <= CURRENT_DATE - INTERVAL '18 years')
);
-- Table: accounts
CREATE TABLE accounts (
account_id SERIAL PRIMARY KEY,
account_number VARCHAR(34) NOT NULL UNIQUE,
customer_id INT NOT NULL,
branch_id INT NOT NULL,
account_type enum_account_type NOT NULL DEFAULT 'SAVINGS',
balance NUMERIC(15, 2) NOT NULL DEFAULT 0.00,
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
account_status enum_account_status NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT fk_accounts_customer FOREIGN KEY (customer_id) REFERENCES customers(customer_id) ON DELETE RESTRICT,
CONSTRAINT fk_accounts_branch FOREIGN KEY (branch_id) REFERENCES branches(branch_id) ON DELETE RESTRICT,
CONSTRAINT chk_account_positive_balance CHECK (balance >= 0.00)
);
-- Table: transactions (Append-Only Ledger)
CREATE TABLE transactions (
transaction_id BIGSERIAL PRIMARY KEY,
transaction_reference VARCHAR(64) NOT NULL UNIQUE,
account_id INT NOT NULL,
transaction_type enum_transaction_type NOT NULL,
amount NUMERIC(15, 2) NOT NULL,
status enum_transaction_status NOT NULL DEFAULT 'PENDING',
transaction_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
description TEXT,
CONSTRAINT fk_transactions_account FOREIGN KEY (account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT,
CONSTRAINT chk_transaction_positive_amount CHECK (amount > 0.00)
);
-- Table: loans
CREATE TABLE loans (
loan_id SERIAL PRIMARY KEY,
customer_id INT NOT NULL,
loan_type enum_loan_type NOT NULL,
principal_amount NUMERIC(15, 2) NOT NULL,
interest_rate NUMERIC(5, 2) NOT NULL,
tenure_months INT NOT NULL,
loan_status enum_loan_status NOT NULL DEFAULT 'APPLIED',
disbursement_date DATE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT fk_loans_customer FOREIGN KEY (customer_id) REFERENCES customers(customer_id) ON DELETE RESTRICT,
CONSTRAINT chk_loan_principal CHECK (principal_amount > 0.00),
CONSTRAINT chk_loan_interest CHECK (interest_rate >= 0.00),
CONSTRAINT chk_loan_tenure CHECK (tenure_months > 0)
);
-- Table: loan_payments (Installment Repayments)
CREATE TABLE loan_payments (
payment_id BIGSERIAL PRIMARY KEY,
loan_id INT NOT NULL,
payment_reference VARCHAR(64) NOT NULL UNIQUE,
installment_amount NUMERIC(15, 2) NOT NULL,
principal_component NUMERIC(15, 2) NOT NULL,
interest_component NUMERIC(15, 2) NOT NULL,
payment_date TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
due_date DATE NOT NULL,
CONSTRAINT fk_loan_payments_loan FOREIGN KEY (loan_id) REFERENCES loans(loan_id) ON DELETE CASCADE,
CONSTRAINT chk_installment_sum CHECK (installment_amount = (principal_component + interest_component)),
CONSTRAINT chk_installment_positive CHECK (installment_amount > 0.00)
);
-- ============================================================================
-- 3. INDEXING STRATEGY
-- ============================================================================
CREATE INDEX idx_accounts_customer_id ON accounts(customer_id);
CREATE INDEX idx_accounts_branch_id ON accounts(branch_id);
CREATE INDEX idx_transactions_account_id ON transactions(account_id);
CREATE INDEX idx_transactions_time ON transactions(transaction_time DESC);
CREATE INDEX idx_loans_customer_id ON loans(customer_id);
CREATE INDEX idx_loan_payments_loan_id ON loan_payments(loan_id);
10. Summary and Key Takeaways
- Dynamic Evaluation: The SQL
CASEexpression provides clean branching logic (WHEN ... THEN ... ELSE ... END) inside projections without altering persisted disk state. - Schema Evolution:
ALTER TABLEoperations modify existing production tables without dropping records. Constraints and defaults must be designed carefully to avoid runtime validation errors. - Relational Integrity Invariants: Primary keys uniquely identify rows, foreign keys prevent orphaned child records, and
ON DELETEreferential actions (RESTRICT,CASCADE) protect relational trees. - Join Semantics:
INNER JOINmatches strict intersections;LEFT JOINpreserves the complete left table filling misses withNULL;FULL OUTER JOINretains unlinked data from both sides;CROSS JOINyields a Cartesian product (). - Pipeline Segregation: The
WHEREclause filters individual rows before aggregation occurs, whereas theHAVINGclause filters aggregated groupings after theGROUP BYexecution phase. - Views as Abstraction Boundaries: Database
VIEWs simplify system interaction by storing complex join operations and aggregate queries behind simple virtual table abstractions. - PL/pgSQL Functions vs. Procedures: Functions compute and return values within standard SQL statements; Stored Procedures execute standalone workflows via
CALLand handle transaction boundaries. - Normalized Banking Architecture: A financial relational schema requires discrete entities (
customers,branches,accounts,transactions,loans,loan_payments) enforced by check constraints (CHECK), custom domain enumerations (ENUM), and append-only ledger mechanisms.