PostgreSQL-FastAPI-Complete-Guide
PostgreSQL-FastAPI-Complete-Guide
This document is written in teaching order. Every section follows the same pattern:
If you can explain each Architecture diagram from memory, you have understood that section.
Table of Contents
students = [
{"id": 1, "name": "Vikas", "age": 20}
]
This works. You can GET , POST , PUT , DELETE on it. But there is one fatal flaw.
Ctrl + C
Start it again:
This is because a Python list lives in RAM (temporary memory). RAM is wiped the moment the process ends.
BEFORE AFTER
------ -----
React React
│ │
▼ ▼
FastAPI FastAPI
│ │
▼ ▼
Python List Pydantic (validation)
│ │
▼ ▼
RAM SQLAlchemy (ORM)
│ │
▼ ▼
LOST ON RESTART psycopg2 (driver)
│
▼
PostgreSQL
│
▼
DISK
│
▼
PERMANENT DATA
FastAPI handles the request. PostgreSQL handles the data. SQLAlchemy is the translator between them, and connection
management is how you make that translation safe and efficient.
(Lesson 16)
A user creates:
My Notes
↓
Learn Linux
↓
Learn FastAPI
↓
Learn Docker
notes = []
Instagram does not lose all users when its server restarts.
Amazon does not lose products.
ChatGPT does not lose conversations.
Math
↓
Vikas 95
Rahul 89
Register
↓
Permanent
↓
Safe
A Database is software used to permanently store, organize, retrieve, and manage data.
Notice the four verbs — they are the whole job description:
Without Database
FastAPI
↓
Python List
↓
RAM
Problems:
With Database
FastAPI
↓
PostgreSQL
↓
Disk
↓
Permanent
Data survives:
Restart
Shutdown
Tomorrow
Next year
Almost everything.
Instagram stores
Amazon stores
React
↓
FastAPI
↓
PostgreSQL
↓
Disk
Reason Meaning
✔ Advanced SQL features JSON columns, full-text search, window functions, extensions like pgvector for AI embeddings
That last point matters for your roadmap: when you build an AI application, PostgreSQL can store your vector embeddings too, using the
pgvector extension. You will not need a separate vector database on day one.
Beginners constantly confuse these. Keep the responsibilities separate in your head.
Authentication ✔ ✘
Returning JSON ✔ ✘
React
↓ HTTP Request
FastAPI
↓ Business Logic
PostgreSQL
↓ Rows
FastAPI
↓ JSON Response
React
psql --version
If it says command not found , that is perfectly fine — it just means PostgreSQL is not installed yet.
Also check:
which psql
And:
Q1. What is a database? A database is software used to permanently store, organize, retrieve, and manage application data efficiently.
Q2. Why can't we store data in Python lists? Python lists exist only in memory (RAM). When the application stops, all data is lost. They
also cannot efficiently handle large amounts of data, concurrent users, searching, or crash recovery.
Q3. Why is PostgreSQL widely used? Because it is open-source, reliable, ACID-compliant, scalable, standards-compliant, supports
advanced SQL features, and integrates very well with modern backend frameworks like FastAPI.
Q4. What is the difference between the backend and the database? The backend (FastAPI) handles requests, validation, and
business logic. The database (PostgreSQL) is responsible for persistent storage and efficient retrieval of data. The backend never stores
data itself — it delegates that to the database.
(Lesson 17)
Goal: Install PostgreSQL, understand every component being installed, and verify that it is running correctly.
PostgreSQL Server
↓
Database Engine
--------------------
PostgreSQL Client
↓
psql Command
--------------------
systemd Service
↓
Runs PostgreSQL automatically
1. PostgreSQL Server ★
This is the actual database. It is a background process that:
It stores your Users, Products, Chats, Orders, Research Papers. Without the server, there is no database.
It is not the database. It is a tool that talks to the database. Later, SQLAlchemy will do the same job programmatically.
systemctl start
systemctl stop
systemctl status
2.3 Architecture
This refreshes Ubuntu's package list so you install the latest available version.
psql --version
Example output:
Meaning: every time Ubuntu boots, PostgreSQL starts automatically. Without this, your FastAPI app will fail to connect after every reboot.
Ubuntu Starts
↓
systemd Starts Services
↓
PostgreSQL Starts
↓
Listens on Port 5432
↓
FastAPI Connects
↓
Reads / Writes Data
Switch to it:
sudo -i -u postgres
vikaskumar@Ubuntu:~$
to:
postgres@Ubuntu:~$
"If your Linux username matches the PostgreSQL username, let you in without a password."
Since the default database superuser is named postgres , you must become the Linux user postgres to log in as the database superuser
postgres . This is a security design — the database is not exposed with a default password.
psql
postgres=#
Ubuntu Terminal
↓
postgres Linux User
↓
psql (client)
↓
PostgreSQL Server
Leave PostgreSQL:
\q
exit
(This step is not in the original lesson, but you cannot connect FastAPI without it.)
Peer authentication works for the terminal, but FastAPI connects over TCP with a username and password. So you need a password.
sudo -i -u postgres
psql
Inside psql:
exit
postgresql://postgres:your_strong_password@localhost:5432/college_db
Connection string:
postgresql://college_user:strong_password_here@localhost:5432/college_db
Why this matters: if your application is ever compromised, the attacker gets only the permissions of college_user — not the ability to
drop every database on the server. This is the principle of least privilege, and interviewers like hearing it.
The -h localhost forces a TCP connection, which is exactly what FastAPI will do. If this works, FastAPI will work.
Permissions Only the postgres user can read the data directory
Run every command yourself. Understanding comes from doing, not reading.
Q1. Why do we install both postgresql and postgresql-contrib ? postgresql installs the database server and client, while postgresql-
contrib provides additional official extensions and utilities commonly used in development and production.
Q2. What is psql ? psql is PostgreSQL's interactive command-line client, used to connect to databases, execute SQL commands, and
administer PostgreSQL.
Q3. Why does PostgreSQL run as a Linux service? Running as a systemd service allows PostgreSQL to start automatically with the
operating system, stay available for applications, restart on failure, and be managed uniformly with systemctl .
Q4. What is the postgres user? It is a dedicated Linux system user created during installation that owns the PostgreSQL data directory
and maps to the default database superuser. PostgreSQL uses peer authentication, so you switch to this Linux user to log in as the database
superuser.
Q6. Why should an application not connect as the postgres superuser? Because of the principle of least privilege — if the
application is compromised, a limited user restricts the damage an attacker can do.
(Lesson 18)
Goal: Understand how PostgreSQL stores data internally before writing any SQL.
This is one of the most important lessons in the entire journey. Whether it is PostgreSQL, MySQL, Oracle, or SQL Server — they all use
almost the same concepts. Learn it once, use it everywhere.
Suppose you are the principal of a college with student information. Would you store it like this?
Vikas AI 20
Rahul ML 21
Aman DL 22
Neha DS 20
+----+--------+-----+------------------+
| ID | Name | Age | Course |
+----+--------+-----+------------------+
| 1 | Vikas | 20 | AI |
| 2 | Rahul | 21 | Machine Learning |
| 3 | Aman | 22 | Deep Learning |
+----+--------+-----+------------------+
PostgreSQL works exactly like this. This is why it is called a relational database — data lives in relations (tables).
Think of it as a folder.
College Database
├── Students Table
├── Teachers Table
├── Courses Table
└── Attendance Table
Instagram Database
Instagram Database
├── Users
├── Posts
├── Comments
├── Likes
└── Followers
AI Study Assistant
├── Users
├── Chats
├── Notes
├── PDFs
├── Agents
├── Research
└── Tasks
Students Table
+----+--------+-----+--------+
| ID | Name | Age | Course |
+----+--------+-----+--------+
| 1 | Vikas | 20 | AI |
| 2 | Rahul | 21 | ML |
+----+--------+-----+--------+
Teachers Table
+----+--------+------------+
| ID | Name | Subject |
+----+--------+------------+
| 1 | Sharma | DBMS |
| 2 | Gupta | Networks |
+----+--------+------------+
Cupboard
├── Clothes Shelf
├── Books Shelf
├── Files Shelf
└── Electronics Shelf
Each shelf stores one type of item. Each table stores one type of data. You would never mix shirts and laptop chargers on the same shelf —
and you never mix students and teachers in the same table.
+----+--------+-----+--------+
| ID | Name | Age | Course |
+----+--------+-----+--------+
| 1 | Vikas | 20 | AI | ← this entire line is ONE row
+----+--------+-----+--------+
Easy Rule
Examples:
One User One Student One Product One Chat One Order
In Python terms: a row is one dictionary; a table is the list of those dictionaries — except it lives on disk instead of RAM.
Columns
↓
ID | Name | Age | Course
--------------------------
1 | Vikas | 20 | AI ← Rows
2 | Rahul | 21 | ML
Column Meaning
id Student ID
Easy Rule
+----+---------+------------------------------+
| ID | User ID | Prompt |
+----+---------+------------------------------+
| 1 | 5 | Explain FastAPI |
| 2 | 5 | What is Kubernetes? |
+----+---------+------------------------------+
Question: How will you identify the correct Rahul? Answer: You need something unique — a Roll Number.
+----+--------+-----+
| ID | Name | Age |
+----+--------+-----+
| 1 | Rahul | 20 |
| 2 | Rahul | 22 |
| 3 | Rahul | 21 |
+----+--------+-----+
Real Examples
Student → Student ID
Product → Product ID
Chat → Chat ID
User → User ID
Money tip: never use FLOAT for currency. Floating point cannot represent 0.10 exactly. Use NUMERIC .
Every production database table looks broadly like this: an identity column, some data columns, a status flag, and timestamps.
PostgreSQL Server
│
▼
Database (college_db)
│
▼
Students Table
│
├── Columns: id | name | age | course | is_active | created_at
│
▼
Rows:
Student 1
Student 2
Student 3
Database: ai_platform
Users Table
Chats Table
Notice the user_id column — that is the foreign key linking each chat to its owner.
sudo -i -u postgres
psql
List databases:
\l
postgres
template0
template1
Exit:
\q
class Student(BaseModel):
name: str
age: int
course: str
students
├── id
├── name
├── age
└── course
Your API model and your database table describe the same student, in two different languages. SQLAlchemy is what keeps them in sync
— that is the entire point of Part 6.
3.13 Assignment
PostgreSQL
│
▼
Database
│
▼
Students Table
│
├── id (INTEGER)
├── name (TEXT)
├── age (INTEGER)
├── course (TEXT)
├── is_active (BOOLEAN)
└── created_at (TIMESTAMP)
│
▼
Rows
Student 1
Student 2
Student 3
If you can explain this diagram without looking at notes, you have understood the core architecture of relational databases.
Q1. What is a database? An organised collection of related data that is stored permanently and managed by a database management
system (DBMS).
Q2. What is a table? A table stores one type of data in rows and columns. For example, a students table stores information only about
Q3. What is the difference between a row and a column? A row represents one complete record (one student). A column represents
one attribute of that record (name, or age).
Q4. What is a Primary Key? A column, or set of columns, that uniquely identifies each row in a table. It cannot contain duplicate or NULL
values.
Q5. What is a Foreign Key? A column that references the primary key of another table, establishing a relationship between the two
tables and enforcing referential integrity.
Q6. Why do databases use data types? Data types guarantee that each column stores the correct kind of data, save storage space,
speed up comparisons, and enable meaningful operations such as arithmetic or date ranges.
Q7. Why is PostgreSQL called a "relational" database? Because data is stored in relations (tables) and tables can be related to each
other through keys, allowing complex queries across connected data.
(Lesson 19)
Goal: Learn the basic SQL commands and create your first real database and table.
"Create a database"?
"Add a student"?
"Show all students"?
SQL = Structured Query Language — the standard language used to communicate with relational databases.
Real-Life Analogy
Imagine PostgreSQL is a librarian. You say:
The librarian understands your request and fetches them. You do not walk into the storage room and search the shelves yourself.
Similarly, FastAPI sends SQL commands to PostgreSQL — it never touches the data files directly.
Architecture
React
↓
FastAPI
↓
SQL
↓
PostgreSQL
↓
Disk
SQL commands a keyword ✔ Yes PostgreSQL server SELECT , CREATE TABLE , INSERT
Meta-commands are shortcuts that exist only inside psql. Your FastAPI application can never run \dt — it can only run SQL.
sudo -i -u postgres
psql
Prompt becomes:
postgres=#
\l
Output:
postgres
template0
template1
Just as Python uses indentation to end a block, SQL uses ; to end a statement. If you press Enter and the prompt changes to
college_db-# instead of college_db=# , it means PostgreSQL is still waiting — you forgot the semicolon.
What Happened?
BEFORE AFTER
------ -----
PostgreSQL PostgreSQL
├── postgres ├── postgres
├── template0 ├── template0
└── template1 ├── template1
└── college_db ← new
\l
Now college_db appears in the list. You created your first database.
Right now you are inside the postgres database. Move into your own:
\c college_db
Prompt changes:
Analogy
Disk
↓
Folder
↓
college_db
↓
Files
Connecting to a database is like opening a folder. Until you \c , every table you create goes into the wrong place — a very common
beginner mistake.
\dt
Output:
Meaning: no tables yet. ("Relation" is the formal database word for a table.)
Line by line:
Part Meaning
Architecture
college_db
│
▼
students
│
├── id
├── name
├── age
└── course
\dt
\d students
Output shows every column with its data type and any constraints. \d is your inspection tool — use it constantly.
Rules to remember:
Strings must use single quotes ' ' — not double quotes. In PostgreSQL, double quotes mean identifier (a column or table name), not
a string.
Numbers do not need quotes.
The values must be in the same order as the columns were defined.
Naming the columns explicitly means your insert will not break when someone adds a new column to the table later.
Output:
+----+--------+-----+------------------+
| id | name | age | course |
+----+--------+-----+------------------+
| 1 | Vikas | 20 | AI |
| 2 | Rahul | 21 | Machine Learning |
| 3 | Aman | 22 | Deep Learning |
+----+--------+-----+------------------+
You have now stored permanent data. Restart your machine — it will still be there.
Understanding SELECT
Think of SELECT as "show me data."
Query Output
SELECT name, course FROM students; Vikas / AI, Rahul / Machine Learning, Aman / Deep Learning
The table you just created works, but a real backend engineer would write it like this:
CHECK (age > 0) Rejects invalid data Database defends itself even if the API has a bug
DEFAULT NOW() Records the creation time Free audit trail, enables ORDER BY created_at
Key lesson: validation happens in two layers. Pydantic validates at the API boundary (fast, friendly error messages). The database
constraints validate at the storage layer (absolute, cannot be bypassed). Professionals use both.
Type each command yourself and watch what changes after every step.
\c college_db
INSERT INTO students (id, name, age, course) VALUES (1, 'Vikas', 20, 'AI');
INSERT INTO students (id, name, age, course) VALUES (2, 'Rahul', 21, 'Machine Learning');
INSERT INTO students (id, name, age, course) VALUES (3, 'Aman', 22, 'Deep Learning');
Task: Create a teachers table with id , name , subject . Insert two teachers and display them.
Q1. What is SQL? Structured Query Language — the standard language used to create, retrieve, update, and delete data in relational
databases such as PostgreSQL.
Q2. What does CREATE DATABASE do? It creates a new database inside the PostgreSQL server, where related tables and data are stored.
Q3. What does CREATE TABLE do? It creates a new table with specified columns, data types, and constraints inside the currently connected
database.
Q4. What does INSERT INTO do? It adds one or more new rows (records) to a table.
Q5. What does SELECT * FROM students; do? It retrieves all columns and all rows from the students table.
Q6. What is SERIAL ? A PostgreSQL pseudo-type that creates an auto-incrementing integer column, typically used for primary keys, so the
database generates IDs automatically.
Q7. Difference between TEXT and VARCHAR(n) ? Both store strings. VARCHAR(n) enforces a maximum length; TEXT has no limit. In
PostgreSQL their performance is essentially identical, so TEXT plus an application-level rule is common.
(Lesson 20)
These are the five commands every backend engineer uses every single day. When your FastAPI backend receives:
GET /students
POST /students
PUT /students/1
DELETE /students/1
it eventually executes one of these SQL commands. This lesson connects REST → FastAPI → SQL → PostgreSQL.
1 Vikas 20 AI
4 Neha 20 AI
Question: What if you only want Vikas? Should PostgreSQL return all students?
C → Create
R → Read
U → Update
D → Delete
This single table is the bridge between everything you learned in Phase 1 and everything in Phase 2. Learn it cold — it is asked
in interviews constantly.
Without WHERE
With WHERE
Real-World Analogy
Your class has 80 students. The teacher says:
She does not call all 80 students and then look for one. She filters. WHERE does exactly this.
More Examples
Comparison Operators
Operator Meaning
= Equal
Multiple Conditions
Useful Extras
Why = NULL fails: NULL means "unknown". Comparing anything to unknown gives unknown, never true. That is why SQL has a
dedicated IS NULL .
UPDATE students
SET course = 'Generative AI'
WHERE id = 1;
Breaking it down:
Result:
BEFORE AFTER
id | course id | course
1 | AI 1 | Generative AI
UPDATE students
SET age = 25;
UPDATE
↓
ALWAYS use WHERE
Result: every row deleted. This mistake has genuinely happened at real companies and caused production outages.
-- Step 2: only if the result is what you expect, run the delete
DELETE FROM students WHERE id = 4;
-- happy?
COMMIT; -- make it permanent
-- made a mistake?
ROLLBACK; -- undo everything since BEGIN
BEGIN ... COMMIT / ROLLBACK is the "undo button" of databases. Remember this — SQLAlchemy sessions use exactly this mechanism
internally, which is why Part 6 talks about commit() and rollback() .
The data is preserved for audits and can be restored. This is why the is_active column exists in the production table design.
Oldest first:
Alphabetical by name:
Real AI Example
Suppose your table has 2 million students. Should PostgreSQL send 2 million rows to the browser?
Formula:
⚠ Always pair LIMIT with ORDER BY . Without an explicit order, PostgreSQL may return rows in any order, so "page 2" could contain
rows you already saw on page 1.
Run these one by one and observe what changes each time.
-- 1. Show all
SELECT * FROM students;
-- 2. Only AI students
SELECT * FROM students WHERE course = 'AI';
-- 5. Delete Aman
DELETE FROM students WHERE name = 'Aman';
SELECT * FROM students; -- observe Aman is gone
-- 6. Sort by name
SELECT * FROM students ORDER BY name;
Frontend
↓ GET /chats?page=1
FastAPI
↓ SQL
SELECT * FROM chats
ORDER BY created_at DESC
LIMIT 20;
↓
PostgreSQL returns 20 latest chats
↓
FastAPI converts rows → Python objects → JSON
↓
Frontend renders the sidebar
SQL Purpose
✘ Mistake ✔ Fix
Q2. Why is WHERE important with UPDATE and DELETE ? Without it, UPDATE modifies every row and DELETE removes every row, which
causes serious data loss.
Q3. What is the purpose of ORDER BY ? It sorts query results in ascending ( ASC , the default) or descending ( DESC ) order based on one or
more columns.
Q4. Why do we use LIMIT ? It restricts the number of rows returned, improving performance and enabling pagination.
Q5. How do you implement pagination in SQL? Combine ORDER BY with LIMIT and OFFSET , where OFFSET = (page - 1) * page_size .
Q6. What is a transaction? A group of SQL statements executed as a single unit. BEGIN starts it, COMMIT makes all changes permanent,
and ROLLBACK undoes them all — guaranteeing the database is never left half-updated.
Q7. Difference between DELETE , TRUNCATE , and DROP ? DELETE removes selected rows and can be rolled back. TRUNCATE quickly
removes all rows but keeps the table structure. DROP removes the table itself, structure included.
(Lesson 21)
Goal: Understand how FastAPI talks to PostgreSQL, and why we use SQLAlchemy instead of writing SQL everywhere.
React
↓
FastAPI ← receives request, runs business logic
↓
Pydantic ← validates the data shape
↓
SQLAlchemy ← translates Python objects into SQL
↓
psycopg2 ← the driver that speaks PostgreSQL's wire protocol
↓
PostgreSQL ← stores and retrieves
↓
Disk ← permanent
You can:
It works. But soon your project contains 500 raw SQL strings scattered across files.
Problems:
✘ Typos are only caught at runtime — the editor cannot check a string
✘ Changing a column name means hunting through every file
✘ Easy to accidentally write SQL vulnerable to SQL injection
✘ Rows come back as plain tuples, not objects
✘ Switching databases means rewriting everything
Solution: SQLAlchemy.
ORM = Object Relational Mapper — a tool that lets you work with database rows as ordinary Python objects instead of writing raw
SQL.
You
↓
Translator
↓
Japanese Person
SQLAlchemy is the translator. You write Python; SQLAlchemy writes the SQL.
Side by Side
Without ORM
With ORM
You wrote Python. SQLAlchemy generated the SQL and sent it.
Python Code
↓
SQLAlchemy
↓
SQL
↓
PostgreSQL
✔ Benefit Explanation
Easier maintenance Column renamed in one model file, not 50 query strings
Objects, not tuples You get a Student object with real attributes
Honest caveat for interviews: ORMs are not always the answer. For very complex reports, analytics queries, or performance-critical
paths, engineers still drop down to raw SQL. SQLAlchemy fully supports this via [Link](text("...")) . The right answer is "ORM by
default, raw SQL where it genuinely helps."
6.5 Installation
source .venv/bin/activate
Install:
Analogy:
Without the car, you cannot reach college. Without psycopg2, Python cannot reach PostgreSQL. SQLAlchemy knows what SQL to write;
psycopg2 knows how to physically send it over TCP port 5432 and read the reply.
FastAPI
↓
SQLAlchemy
↓
psycopg2
↓
PostgreSQL
Note: psycopg2-binary ships pre-compiled and is perfect for development. The newer driver is psycopg (version 3) — installed as pip
install "psycopg[binary]" with the URL prefix postgresql+psycopg:// . Both are fine; this document uses psycopg2 as in the lesson.
DATABASE_URL = "postgresql://postgres:password@localhost:5432/college_db"
Anatomy
When you later move to Docker, localhost becomes the service name (e.g. db ). When you move to AWS RDS, it becomes a long
hostname. Only this one string changes — that is why it lives in a config file, never hard-coded in your routes.
engine = create_engine(DATABASE_URL)
The Engine is the connection factory and the home of the connection pool. It is created once for the entire application.
Do not create an engine per request — opening a TCP connection and authenticating takes milliseconds, and doing it thousands of times
per second will destroy your performance. The engine keeps a pool of already-open connections and hands them out.
Customer arrives
↓
Talks to the cashier
↓
Transaction completed
↓
Customer leaves
↓
Next customer
Databases work the same way. Every request gets its own session.
Connection The counter itself Borrowed from the pool, returned after
class Base(DeclarativeBase):
pass
Every database model inherits from this. Base collects the metadata of all your tables so SQLAlchemy can create them, inspect them, and
migrate them.
Remember Pydantic?
class Student(Base):
__tablename__ = "students"
Beginners mix these up constantly. They look similar and solve completely different problems.
Lives at The edge of your app (HTTP boundary) The bottom of your app (database)
Incoming JSON
↓
Pydantic (StudentCreate) ← "is this valid input?"
↓
SQLAlchemy (Student) ← "save this as a row"
↓
PostgreSQL
↓
SQLAlchemy (Student) ← "here is the row as an object"
↓
Pydantic (StudentResponse) ← "what should the client see?"
↓
Outgoing JSON
Why two models and not one? Because what a client is allowed to send is not the same as what you store. A client sends a plain
password; you store a hash. A client never sends id or created_at ; the database generates them. You never send password_hash
back to the client. Two models keep those concerns cleanly separated — this is a very common interview question.
[Link].create_all(bind=engine)
If the students table does not exist, SQLAlchemy creates it from your model.
⚠ Important limitation: create_all only creates missing tables. It will never alter an existing table. If you add a column to
your model later, create_all silently does nothing and your app crashes with "column does not exist". The professional solution is
Alembic, the migration tool for SQLAlchemy. For now, create_all is fine for learning — just know why it is temporary.
db = SessionLocal()
Create
-- SQL
INSERT INTO students (name, age, course) VALUES ('Vikas', 20, 'AI');
# ORM
student = Student(name="Vikas", age=20, course="AI")
[Link](student)
[Link]()
[Link](student) # reload the DB-generated id
Read All
students = [Link](Student).all()
Update
[Link] = 21
[Link]()
Delete
[Link](student)
[Link]()
Order + Limit
students = (
[Link](Student)
.order_by([Link]())
.limit(20)
.offset(0)
.all()
)
Notice: you never wrote SQL. But the SQL you learned in Parts 4 and 5 is exactly what is being generated — which is why those parts came
first.
Debugging tip: pass create_engine(DATABASE_URL, echo=True) and SQLAlchemy will print every SQL statement it generates. Do this
once while learning — watching your Python turn into the SQL you already know is the moment the ORM concept truly clicks.
React
↓ GET /students
FastAPI Router
↓
Dependency: get_db() → opens a Session
↓
Service layer (business logic)
↓
SQLAlchemy query
↓
Generated SQL
↓
psycopg2 → port 5432
↓
PostgreSQL
↓
Rows
↓
Python Student objects
↓
Pydantic StudentResponse
↓
JSON
↓
React
(and finally: get_db() closes the Session)
Q1. What is an ORM? An Object Relational Mapper is a tool that lets developers interact with relational databases using programming-
language objects instead of writing raw SQL queries.
Q2. Why do we use SQLAlchemy? It simplifies database interaction, improves readability, reduces repetitive SQL, protects against SQL
injection through parameter binding, provides database abstraction, manages connection pooling, and integrates cleanly with FastAPI's
dependency injection.
Q3. Difference between a Pydantic model and a SQLAlchemy model? Pydantic models validate API requests and responses and are
used by FastAPI. SQLAlchemy models represent database tables and are used by the ORM to map Python objects to database rows. They
serve different layers of the application.
Q4. Why do we need psycopg2? psycopg2 is the PostgreSQL database driver. It implements PostgreSQL's wire protocol so Python —
through SQLAlchemy — can physically connect to and communicate with a PostgreSQL server.
Q5. What is the difference between the Engine and the Session? The Engine is created once per application and manages the
connection pool. A Session is short-lived — typically one per request — and represents a single unit of work (a transaction) against the
database.
Q6. What are the disadvantages of an ORM? It adds an abstraction layer that can generate inefficient SQL if used carelessly (for
example the N+1 query problem), it has a learning curve, and very complex analytical queries are often clearer and faster written as raw
SQL.
This is the part that separates a tutorial project from a production backend.
db = SessionLocal()
students = [Link](Student).all()
What is missing?
An unclosed session holds a connection from the pool. Leak enough of them and your application freezes with QueuePool limit of size 5
overflow 10 reached — a classic production incident.
def get_db():
db = SessionLocal()
try:
yield db # hand the session to the route
finally:
[Link]() # ALWAYS runs, even on exception
return yield
FastAPI treats a generator dependency as "setup → run the endpoint → teardown". The finally block guarantees the session is
returned to the pool no matter what happens — success, validation error, or unhandled exception.
Using It in a Route
@[Link]("/students")
def list_students(db: Session = Depends(get_db)):
return [Link](Student).all()
@[Link]("/students")
def list_students(db: DbSession):
return [Link](Student).all()
Request arrives
↓
get_db() → SessionLocal() → borrows a connection from the pool
↓
yield db
↓
Route function runs → queries, add, commit
↓
Response is built and sent
↓
finally: [Link]() → connection RETURNED to the pool
The connection is borrowed, not created. That is the whole point of pooling.
Opening a PostgreSQL connection requires a TCP handshake plus authentication — a few milliseconds each time. At 1,000 requests per
second, that is unacceptable overhead.
A pool keeps a set of connections permanently open and lends them out.
Connection Pool
┌───────────────────────┐
│ conn1 conn2 conn3 │ ← kept open
└───────────────────────┘
↑ ↓
returned borrowed
↑ ↓
Request A Request B
engine = create_engine(
DATABASE_URL,
pool_size=10, # connections kept permanently open
max_overflow=20, # extra connections allowed during traffic spikes
pool_timeout=30, # seconds to wait for a free connection before erroring
pool_recycle=1800, # recycle a connection after 30 min (avoids stale ones)
pool_pre_ping=True, # test the connection before use — heals dead sockets
echo=False, # True prints every generated SQL statement
)
pool_size Too small → requests queue up. Too large → PostgreSQL runs out of connections (default max is 100).
pool_pre_ping The single most valuable setting. Firewalls and cloud databases silently drop idle connections; without pre-ping your app
throws random server closed the connection unexpectedly errors.
pool_recycle Prevents connections living longer than the server or firewall allows
Interview-ready line: "Total possible connections = ( pool_size + max_overflow ) × number of application workers. That total must
stay below PostgreSQL's max_connections ."
[Link](obj) Reloads the object from the database After commit, to get the generated id and created_at
[Link]() Sends SQL but does not commit Rarely, when you need an ID mid-transaction
The id and created_at are generated by the database, not by Python. refresh fetches them back.
try:
[Link](student)
[Link]()
[Link](student)
except SQLAlchemyError:
[Link]()
raise HTTPException(status_code=500, detail="Database error")
rollback() is the ROLLBACK; from Part 5.5 — the same concept, called from Python.
This is wrong:
DATABASE_URL = "postgresql://postgres:MyPassword123@localhost:5432/college_db"
Because:
DATABASE_URL=postgresql://college_user:strong_password@localhost:5432/college_db
.gitignore
.env
.venv/
__pycache__/
app/core/[Link]
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
DATABASE_URL: str
APP_NAME: str = "College API"
DEBUG: bool = False
settings = Settings()
Now the URL comes from the environment. On your laptop it points at localhost; in Docker it points at the db container; on AWS it points at
RDS. Zero code changes.
app/
├── [Link] ← creates the FastAPI app, includes routers
│
├── core/
│ └── [Link] ← settings loaded from .env
│
├── database/
│ ├── [Link] ← engine, SessionLocal, Base, get_db
│ └── __init__.py
│
├── models/ ← SQLAlchemy models (database tables)
│ └── [Link]
│
├── schemas/ ← Pydantic models (API contracts)
│ └── [Link]
│
├── services/ ← business logic + database operations
│ └── student_service.py
│
└── routers/ ← HTTP endpoints only
└── [Link]
The benefit: if you later replace REST with GraphQL, you rewrite only routers/ . If you switch databases, you touch only models/ and
database/ . Each layer changes for exactly one reason.
@asynccontextmanager
async def lifespan(app: FastAPI):
# startup
[Link].create_all(bind=engine)
yield
# shutdown
[Link]() # closes every pooled connection cleanly
app = FastAPI(lifespan=lifespan)
Every production service needs a way to prove the database is reachable — Kubernetes and load balancers use exactly this.
@[Link]("/health")
def health_check(db: Session = Depends(get_db)):
try:
[Link](text("SELECT 1"))
return {"status": "ok", "database": "connected"}
except Exception:
raise HTTPException(status_code=503, detail="Database unavailable")
SELECT 1 is the cheapest possible query — it proves the connection works without touching any table.
Everything above, assembled into a project you can actually run. This is the code that replaces students = [] for good.
# 2. Virtual environment
python3 -m venv .venv
source .venv/bin/activate
# 3. Install dependencies
pip install fastapi uvicorn sqlalchemy psycopg2-binary pydantic-settings
# 4. Freeze them
pip freeze > [Link]
8.1 .env
DATABASE_URL=postgresql://college_user:strong_password_here@localhost:5432/college_db
APP_NAME=College API
DEBUG=True
And .gitignore :
.env
.venv/
__pycache__/
*.pyc
8.2 app/core/[Link]
class Settings(BaseSettings):
"""Application settings, loaded from environment variables / .env file."""
DATABASE_URL: str
APP_NAME: str = "College API"
DEBUG: bool = False
settings = Settings()
# ---------------------------------------------------------------
# 1. ENGINE — created ONCE for the whole application.
# It owns the connection pool.
# ---------------------------------------------------------------
engine = create_engine(
settings.DATABASE_URL,
pool_size=10, # connections kept permanently open
max_overflow=20, # extra connections allowed during spikes
pool_timeout=30, # seconds to wait for a free connection
pool_recycle=1800, # recycle connections every 30 minutes
pool_pre_ping=True, # verify a connection is alive before using it
echo=[Link], # print generated SQL while developing
)
# ---------------------------------------------------------------
# 2. SESSION FACTORY — produces one Session per request.
# ---------------------------------------------------------------
SessionLocal = sessionmaker(
bind=engine,
autocommit=False, # we control commits explicitly
autoflush=False, # no surprise writes before we ask
expire_on_commit=False # objects stay usable after commit()
)
# ---------------------------------------------------------------
# 3. BASE — every model inherits from this.
# ---------------------------------------------------------------
class Base(DeclarativeBase):
pass
# ---------------------------------------------------------------
# 4. DEPENDENCY — one session per request, always closed.
# ---------------------------------------------------------------
def get_db():
"""
FastAPI dependency.
class Student(Base):
"""Maps to the 'students' table in PostgreSQL."""
__tablename__ = "students"
Why index=True on name ? Because you will filter by name. Without an index, PostgreSQL scans every row ( O(n) ); with one, it uses a
B-tree ( O(log n) ). Primary keys are indexed automatically.
class StudentBase(BaseModel):
"""Fields shared by create and update."""
name: str = Field(..., min_length=2, max_length=100)
age: int = Field(..., gt=0, lt=120)
course: str = Field(..., min_length=2, max_length=100)
class StudentCreate(StudentBase):
"""What the client sends on POST. No id, no created_at."""
pass
class StudentUpdate(BaseModel):
"""What the client sends on PATCH. Everything optional."""
name: str | None = Field(None, min_length=2, max_length=100)
age: int | None = Field(None, gt=0, lt=120)
course: str | None = Field(None, min_length=2, max_length=100)
is_active: bool | None = None
class StudentResponse(StudentBase):
"""What the API sends back."""
model_config = ConfigDict(from_attributes=True)
id: int
is_active: bool
created_at: datetime
from_attributes=True (called orm_mode in Pydantic v1) tells Pydantic: "you may read values from object attributes, not just dictionary
keys." Without it, FastAPI cannot convert a SQLAlchemy Student object into JSON.
def get_all_students(
db: Session, skip: int = 0, limit: int = 20
) -> list[Student]:
"""SELECT * FROM students ORDER BY id LIMIT :limit OFFSET :skip;"""
return (
[Link](Student)
.order_by([Link])
.offset(skip)
.limit(limit)
.all()
)
def update_student(
db: Session, student_id: int, payload: StudentUpdate
) -> Student | None:
"""UPDATE students SET ... WHERE id = :student_id;"""
student = get_student_by_id(db, student_id)
if student is None:
return None
[Link]()
[Link](student)
return student
[Link](student)
[Link]()
return True
Every function has its SQL equivalent written in the docstring — keep that habit while learning, so you always know what the ORM is really
doing.
@[Link]("", response_model=list[StudentResponse])
def list_students(
db: DbSession,
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
):
skip = (page - 1) * size
return student_service.get_all_students(db, skip=skip, limit=size)
@[Link]("/{student_id}", response_model=StudentResponse)
def get_student(student_id: int, db: DbSession):
student = student_service.get_student_by_id(db, student_id)
if student is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Student {student_id} not found",
)
return student
@[Link](
"", response_model=StudentResponse, status_code=status.HTTP_201_CREATED
)
def create_student(payload: StudentCreate, db: DbSession):
return student_service.create_student(db, payload)
@[Link]("/{student_id}", response_model=StudentResponse)
def update_student(student_id: int, payload: StudentUpdate, db: DbSession):
student = student_service.update_student(db, student_id, payload)
if student is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Student {student_id} not found",
)
return student
@[Link]("/{student_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_student(student_id: int, db: DbSession):
deleted = student_service.delete_student(db, student_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Student {student_id} not found",
)
Notice: no SQL and no session creation in this file. The router only knows about HTTP.
8.8 app/[Link]
@asynccontextmanager
async def lifespan(app: FastAPI):
# --- startup ---
[Link].create_all(bind=engine)
yield
# --- shutdown ---
[Link]()
app = FastAPI(
title=settings.APP_NAME,
version="1.0.0",
lifespan=lifespan,
)
app.include_router([Link])
@[Link]("/")
def root():
return {"message": f"{settings.APP_NAME} is running"}
@[Link]("/health")
def health_check(db: Session = Depends(get_db)):
try:
[Link](text("SELECT 1"))
return {"status": "ok", "database": "connected"}
except Exception:
raise HTTPException(status_code=503, detail="Database unavailable")
8.9 Run It
[Link]
8.10 Test It
# Health
curl [Link]
# Create
curl -X POST [Link] \
-H "Content-Type: application/json" \
-d '{"name": "Vikas", "age": 20, "course": "AI"}'
# List
curl [Link]
# Get one
curl [Link]
# Update
curl -X PATCH [Link] \
-H "Content-Type: application/json" \
-d '{"age": 21}'
# Delete
curl -X DELETE [Link]
The real proof — the data exists outside your Python process:
Now stop the server with Ctrl + C , start it again, and call GET /students .
connection refused ... port 5432 PostgreSQL is not running sudo systemctl start postgresql
password authentication failed Wrong password, or no Re-run ALTER USER ... WITH PASSWORD
password set
database "college_db" does not exist Database never created CREATE DATABASE college_db;
role "college_user" does not exist User never created CREATE USER ...
relation "students" does not exist Tables never created Ensure the model is imported and create_all runs
column ... does not exist Model changed but table did not Drop the table, or use Alembic migrations
QueuePool limit ... reached Sessions not being closed Use Depends(get_db) with try/finally
permission denied for table students User lacks rights GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO
college_user;
psql --version
which psql
\l List databases
\c college_db Connect to a database
\dt List tables
\d students Describe a table
\du List roles/users
\conninfo Current connection info
\x Toggle expanded output
\timing Show query execution time
\h SELECT SQL help
\? Meta-command help
\q Quit
-- DATABASE
CREATE DATABASE college_db;
DROP DATABASE college_db;
-- TABLE
CREATE TABLE students (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
age INTEGER NOT NULL CHECK (age > 0),
course TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
ALTER TABLE students ADD COLUMN email TEXT;
DROP TABLE students;
-- CREATE
INSERT INTO students (name, age, course) VALUES ('Vikas', 20, 'AI');
-- READ
SELECT * FROM students;
SELECT name, course FROM students;
SELECT * FROM students WHERE age > 20;
SELECT * FROM students WHERE course IN ('AI', 'ML');
SELECT * FROM students WHERE name LIKE 'R%';
SELECT * FROM students ORDER BY age DESC;
SELECT * FROM students ORDER BY id LIMIT 20 OFFSET 20;
SELECT COUNT(*) FROM students;
SELECT course, COUNT(*) FROM students GROUP BY course;
-- UPDATE
UPDATE students SET course = 'Generative AI' WHERE id = 1;
-- DELETE
DELETE FROM students WHERE id = 4;
-- TRANSACTIONS
BEGIN;
UPDATE students SET age = 25 WHERE id = 1;
ROLLBACK; -- or COMMIT;
SQL SQLAlchemy
SELECT * FROM students WHERE age > 20; [Link](Student).filter([Link] > 20).all()
ROLLBACK [Link]()
If you can place any new concept into one of these four layers, you understand it.
Databases
PostgreSQL Setup
SQL
14. What does the WHERE clause do, and why is it critical with UPDATE and DELETE ?
15. Explain ORDER BY , LIMIT , and OFFSET . How do you paginate?
16. Difference between DELETE , TRUNCATE , and DROP ?
ORM / FastAPI
"Data in a Python list lives in RAM and disappears when the process ends, so real applications store data in a database. PostgreSQL
stores it on disk in tables made of rows and columns, where each row is uniquely identified by a primary key. We talk to PostgreSQL
using SQL — INSERT , SELECT , UPDATE , DELETE , filtered with WHERE , sorted with ORDER BY , and paginated with LIMIT and OFFSET .
Writing those queries as raw strings everywhere is unmaintainable, so we use SQLAlchemy, an ORM that translates Python objects into
SQL, with psycopg2 as the driver that physically sends it over port 5432. In FastAPI, the engine is created once and owns a connection
pool, while each request gets its own session through a Depends(get_db) dependency that uses yield inside try/finally so the
connection is always returned to the pool. Pydantic validates the data at the HTTP boundary, SQLAlchemy models describe the tables,
and the credentials come from a .env file so the same code runs on a laptop, in Docker, and on AWS."
The N+1 problem The classic ORM performance trap; fixed with joinedload