๐ Python and PostgreSQL Integration
A database is only useful once a program can talk to it. In this lesson you'll connect Python to PostgreSQL with the psycopg driver โ writing queries that are safe from SQL injection, wrapping work in transactions, choosing the right cursor, pooling connections, and loading data in bulk.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Choose the right abstraction level โ driver, query helper, or ORM โ for a task
- Connect to PostgreSQL from Python and run parameterized queries that resist SQL injection
- Manage transactions correctly with commit and rollback
- Pick the right cursor type and fetch results efficiently, even for huge result sets
- Use connection pooling and bulk loading (
COPY) for performance
Estimated Time: 50โ70 minutes โข Difficulty: Intermediate
Hands-on: Write a safe, transactional "bank transfer" function that moves money between two accounts atomically.
In This Lesson
Three Levels of Database Access
Python can talk to PostgreSQL at three different heights of abstraction. Each trades control for convenience.
SQLAlchemy ยท Django ORM] L3 --> L2[Level 2: Query helpers
Records ยท asyncpg helpers] L2 --> L1[Level 1: Driver
psycopg ยท asyncpg] L1 --> DB[(PostgreSQL)]
๐ก An analogy โ levels of driving assistance: A raw driver (Level 1) is a manual transmission: total control, every gear change is yours. A query helper (Level 2) is an automatic with cruise control โ you still steer, but the tedious bits are handled. An ORM (Level 3) is adaptive cruise with lane-keeping: you set the destination in Python objects and it writes the SQL. None is "best" โ different roads call for different amounts of assistance.
๐ Meet the driver: psycopg
psycopg is the standard PostgreSQL adapter for Python. psycopg3 (imported as import psycopg) is the current version and the one to prefer for new projects. psycopg2 (import psycopg2) is the previous generation โ still everywhere in existing codebases, so you'll see both. The concepts are nearly identical; this lesson shows psycopg3 with notes where psycopg2 differs.
This lesson lives at Level 1. Understanding the driver makes the ORM in the next lesson feel like magic you can explain, not magic you fear.
Connecting Safely
First install the driver into a virtual environment:
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install "psycopg[binary]" # psycopg3 with prebuilt binaries
# (older projects: pip install psycopg2-binary)
โ ๏ธ Never hardcode credentials
Connection secrets belong in environment variables (or a secrets manager), never in source code that lands in Git. Read them at runtime.
The safest connection pattern uses a with block, which guarantees the connection is committed-or-rolled-back and closed even if an error is raised:
import os
import psycopg
# Build a connection string from environment variables
conninfo = (
f"host={os.environ.get('DB_HOST', 'localhost')} "
f"port={os.environ.get('DB_PORT', '5432')} "
f"dbname={os.environ['DB_NAME']} "
f"user={os.environ['DB_USER']} "
f"password={os.environ['DB_PASSWORD']}"
)
# The 'with' block manages the transaction and closes the connection
with psycopg.connect(conninfo) as conn:
with conn.cursor() as cur:
cur.execute("SELECT version();")
print(cur.fetchone()[0])
# On a clean exit the transaction is committed; on an exception it is rolled back.
๐ก A URL works too
PostgreSQL accepts a single connection URL, which is handy for cloud providers that hand you one:
url = os.environ["DATABASE_URL"] # postgresql://user:pass@host:5432/dbname
with psycopg.connect(url) as conn:
...
Parameterized Queries
This is the single most important habit in this lesson. Never build SQL by pasting user input into a string. Doing so opens the door to SQL injection, where a crafted input rewrites your query.
โ ๏ธ The vulnerability
# DANGER โ never do this
username = request_input # imagine: "'; DROP TABLE users; --"
cur.execute(f"SELECT * FROM users WHERE username = '{username}'")
# The attacker's text becomes part of your SQL.
Instead, pass values separately using %s placeholders. The driver sends the query and the data apart, so the data can never be interpreted as SQL:
# SAFE โ the driver escapes the value for you
username = "johndoe"
cur.execute("SELECT id, email FROM users WHERE username = %s", (username,))
user = cur.fetchone()
print(user) # e.g. (1, 'john@example.com')
โ ๏ธ %s is always the placeholder โ even for numbers
It's a psycopg placeholder, not Python string formatting. Use %s for every value regardless of type, and always pass a tuple (note the trailing comma for a single value: (username,)).
RETURNING gives you generated values
When you insert a row with an identity key, ask PostgreSQL to hand the new id straight back:
import datetime
cur.execute(
"""
INSERT INTO orders (customer_id, items, total, ordered_at, is_shipped, notes)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
42, # integer
["Widget", "Gadget"], # -> a PostgreSQL text[] array
99.95, # numeric
datetime.datetime.now(datetime.UTC), # timestamptz
True, # boolean
"Priority shipping", # text
),
)
new_id = cur.fetchone()[0]
print(f"Created order {new_id}")
Notice psycopg maps Python types to PostgreSQL types for free: a list becomes an array, a datetime becomes a timestamp, a dict can become JSON.
Transactions
A transaction groups several statements so they all succeed or all fail together โ the "A" (atomicity) in ACID. The classic example is a bank transfer: debiting one account and crediting another must never happen halfway.
With psycopg3, the with conn: block is the transaction boundary. Exiting cleanly commits; raising an exception rolls back:
with psycopg.connect(conninfo) as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE accounts SET balance = balance - %s WHERE id = %s",
(100, 1),
)
cur.execute(
"UPDATE accounts SET balance = balance + %s WHERE id = %s",
(100, 2),
)
# Reaching here without an exception commits both updates atomically.
# If either statement raised, neither change is saved.
Deciding to roll back yourself
Sometimes the data itself tells you to abort โ for example, insufficient funds. Raise an exception to trigger the automatic rollback:
class InsufficientFunds(Exception):
pass
try:
with psycopg.connect(conninfo) as conn:
with conn.cursor() as cur:
cur.execute("SELECT balance FROM accounts WHERE id = %s FOR UPDATE", (1,))
balance = cur.fetchone()[0]
if balance < 100:
raise InsufficientFunds("Not enough money")
cur.execute("UPDATE accounts SET balance = balance - 100 WHERE id = 1")
cur.execute("UPDATE accounts SET balance = balance + 100 WHERE id = 2")
except InsufficientFunds as e:
print(f"Transfer cancelled: {e}") # the whole transaction was rolled back
๐ก FOR UPDATE and autocommit
SELECT ... FOR UPDATE locks the row so a second concurrent transfer can't read a stale balance. For one-off statements where each should commit immediately (like logging), set conn.autocommit = True and each execute stands alone.
Cursors & Fetching Results
A cursor runs a query and steps through its results. By default rows come back as plain tuples, addressed by position:
with conn.cursor() as cur:
cur.execute("SELECT id, username, email FROM users")
row = cur.fetchone() # one row, or None
some = cur.fetchmany(3) # a list of up to 3 rows
rest = cur.fetchall() # every remaining row
# Cursors are iterable too:
for r in cur:
print(r[0], r[1])
Getting rows as dictionaries
Accessing row[2] is fragile โ reorder the columns and your code breaks. Ask for dictionary rows and address fields by name:
from psycopg.rows import dict_row
with conn.cursor(row_factory=dict_row) as cur:
cur.execute("SELECT id, username, email FROM users LIMIT 1")
user = cur.fetchone()
print(user["username"], user["email"]) # readable and reorder-proof
psycopg2 equivalent: pass cursor_factory=psycopg2.extras.RealDictCursor instead.
Huge result sets: server-side cursors
Calling fetchall() on a million-row table loads a million rows into memory. A named (server-side) cursor streams them in batches instead:
with conn.cursor(name="big_export") as cur: # a name makes it server-side
cur.execute("SELECT * FROM logs")
while batch := cur.fetchmany(1000):
for row in batch:
process(row) # memory stays flat
Bulk Operations & Pooling
Loading many rows fast with COPY
Inserting rows one execute at a time is fine for a handful, painfully slow for thousands. PostgreSQL's COPY is the fastest path for bulk loads, and psycopg3 exposes it cleanly:
rows = [
("INFO", f"Log message {i}") for i in range(1, 100_001)
]
with conn.cursor() as cur:
with cur.copy("COPY logs (level, message) FROM STDIN") as copy:
for row in rows:
copy.write_row(row)
# 100,000 rows in a fraction of the time a loop of INSERTs would take.
For a moderate batch where you still want plain SQL, executemany is a good middle ground:
cur.executemany(
"INSERT INTO logs (level, message) VALUES (%s, %s)",
rows[:1000],
)
Connection pooling
Opening a new connection per request is expensive โ the TCP handshake and authentication add up. A connection pool keeps a set of connections warm and hands them out on demand. In psycopg3 the pool lives in a separate package:
pip install "psycopg[pool]"
from psycopg_pool import ConnectionPool
# Create one pool for the whole application (min 2, max 10 connections)
pool = ConnectionPool(conninfo, min_size=2, max_size=10)
def get_user(user_id: int):
# Borrow a connection; it returns to the pool automatically at block exit
with pool.connection() as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
return cur.fetchone()
# On application shutdown:
pool.close()
โ Rules of thumb
- One pool per application, created at startup โ not per request.
- Always borrow with a
withblock so connections return to the pool. - For very high concurrency, put an external pooler like PgBouncer in front of the database.
Error Handling
psycopg raises a hierarchy of exceptions that mirror PostgreSQL's error conditions. Catching specific ones lets you respond intelligently โ a duplicate key is a user-facing "already taken", a connection drop is a retry.
connection issues] DBE --> IN[IntegrityError
unique / FK / check] DBE --> PR[ProgrammingError
bad SQL] DBE --> DA[DataError
bad values]
import psycopg
from psycopg import errors
try:
with psycopg.connect(conninfo) as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO users (username, email) VALUES (%s, %s)",
("johndoe", "john@example.com"),
)
except errors.UniqueViolation:
print("That username or email is already taken.") # a 409 to the user
except psycopg.OperationalError as e:
print(f"Database unavailable, will retry: {e}") # transient โ retry
except psycopg.Error as e:
print(f"Unexpected database error: {e}") # catch-all fallback
๐ก Let the transaction do the cleanup
Because the with conn: block rolls back automatically on any exception, you rarely need an explicit conn.rollback(). Focus your except clauses on deciding what to tell the caller, not on undoing partial writes.
Hands-on Exercise
๐๏ธ An atomic bank transfer
Objective: Write a function transfer(pool, from_id, to_id, amount) that moves money between two accounts, all-or-nothing, and refuses to overdraw.
Setup:
CREATE TABLE accounts (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
owner TEXT NOT NULL,
balance NUMERIC(12,2) NOT NULL CHECK (balance >= 0)
);
INSERT INTO accounts (owner, balance) VALUES ('Ana', 500), ('Ben', 100);
Requirements:
- Do the debit and credit inside a single transaction.
- Lock the source row with
FOR UPDATEbefore checking its balance. - Raise an exception (rolling everything back) if funds are insufficient.
- Use parameterized queries throughout.
๐ก Hint
Borrow a connection from the pool with with pool.connection() as conn:. The with block is your transaction โ raising inside it triggers the rollback for you. Read the balance with SELECT ... FOR UPDATE, compare to amount, then run the two UPDATEs.
โ Solution
from decimal import Decimal
from psycopg_pool import ConnectionPool
class InsufficientFunds(Exception):
pass
def transfer(pool: ConnectionPool, from_id: int, to_id: int, amount: Decimal) -> None:
if amount <= 0:
raise ValueError("amount must be positive")
with pool.connection() as conn: # this block is the transaction
with conn.cursor() as cur:
cur.execute(
"SELECT balance FROM accounts WHERE id = %s FOR UPDATE",
(from_id,),
)
row = cur.fetchone()
if row is None:
raise ValueError("source account not found")
if row[0] < amount:
raise InsufficientFunds(f"balance {row[0]} < {amount}")
cur.execute(
"UPDATE accounts SET balance = balance - %s WHERE id = %s",
(amount, from_id),
)
cur.execute(
"UPDATE accounts SET balance = balance + %s WHERE id = %s",
(amount, to_id),
)
# clean exit -> COMMIT; any raise above -> ROLLBACK
# Usage
pool = ConnectionPool(conninfo, min_size=1, max_size=5)
try:
transfer(pool, 1, 2, Decimal("150.00"))
print("Transfer complete")
except InsufficientFunds as e:
print(f"Declined: {e}")
๐ฏ Quick Quiz
Question 1: How should user input be included in a SQL query with psycopg?
Question 2: In psycopg3, what does a clean exit from a with psycopg.connect(...) as conn: block do?
Question 3: Which approach is best for loading 100,000 rows as fast as possible?
Summary & Quiz
๐ Key Takeaways
- Python reaches PostgreSQL at three levels โ driver, helper, ORM; this lesson used the driver, psycopg (prefer psycopg3).
- Always use parameterized queries (
%s+ a tuple) โ the one non-negotiable habit against SQL injection. - The
with conn:block is your transaction boundary: clean exit commits, an exception rolls back. - Choose dictionary rows for readable code and server-side cursors for huge results.
- Connection pools and
COPYare your performance workhorses; catch specific exceptions to respond well.
๐ Further Reading
๐ What's Next?
Writing raw SQL by hand is powerful but verbose. Next, ORM Basics with SQLAlchemy shows how to map Python classes to tables so you can work with objects instead of strings โ while still dropping down to SQL whenever you need to.
๐ Nicely done!
You can now drive PostgreSQL from Python safely and fast. Time to let an ORM write the boilerplate for you.