Skip to main content

๐Ÿ 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.

flowchart TD APP[Your Python code] --> L3[Level 3: ORM
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.

flowchart LR BEGIN([BEGIN]) --> S1[Debit account A] S1 --> S2[Credit account B] S2 --> OK{All good?} OK -->|yes| C([COMMIT]) OK -->|no| R([ROLLBACK])

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 with block 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.

flowchart TD E[Error] --> DBE[DatabaseError] DBE --> OP[OperationalError
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:

  1. Do the debit and credit inside a single transaction.
  2. Lock the source row with FOR UPDATE before checking its balance.
  3. Raise an exception (rolling everything back) if funds are insufficient.
  4. 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 COPY are 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.