AlgoMaster Logo

Connecting to Databases

Medium Priority17 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

SQLite is friction-free because the database lives in the same process as the application. Every other database (PostgreSQL, MySQL, SQL Server) runs as a separate server, often on a separate machine. Connecting to one is a small problem with many details: which driver to install, how to assemble a connection string, where the password lives, how long to wait when the network is slow, how to keep connections from leaking. This lesson covers the patterns that production Python code uses to connect to real database servers safely.

What Changes When the Database Is Remote

A local SQLite file responds in microseconds. A network database adds three new costs to design around.

The first is latency. A query that runs in 1 ms on the server still pays the network round-trip on top: 1-2 ms inside a data center, 5-50 ms across regions, more from a laptop to a cloud database. Code that fires off 1000 small queries one at a time feels fine against SQLite and crawls against a remote PostgreSQL.

The second is failure. The network can drop a connection between the moment it opens and the moment it's used. The server can restart for maintenance. The DNS lookup can time out. Any of these turns a conn.execute(...) call into an exception. Production code has to handle that.

The third is authentication. SQLite trusts whoever can open the file. A network database asks for a username and password (or a certificate, or an IAM token) before granting access. Where those credentials live, who can read them, and how they get rotated turns into a real concern the moment the database is shared.

The DB-API loop (connect, cursor, execute, fetch, commit, close) doesn't change. What changes is what goes into connect, what can go wrong, and how to recover.

The Common Drivers

Each database has at least one Python driver, and almost all of them follow DB-API 2.0. Picking the right one matters because the API stays the same but the feature set, performance, and maintenance status differ.

DatabaseDriver(s)InstallNotes
PostgreSQLpsycopg (v3), psycopg2, asyncpgpip install psycopg[binary]psycopg (v3) is the modern default; psycopg2 is the older line still in heavy use. asyncpg is for asyncio.
MySQL / MariaDBmysql-connector-python, PyMySQL, mysqlclientpip install mysql-connector-pythonmysql-connector-python is Oracle's official driver. PyMySQL is pure Python. mysqlclient is the fast C extension.
Microsoft SQL Serverpyodbc, pymssqlpip install pyodbcpyodbc is the standard, but it needs the system ODBC driver installed too.
SQLitesqlite3built inStandard-library driver for SQLite.
Oracleoracledbpip install oracledbReplaces the old cx_Oracle.

Two practical notes on PostgreSQL because it shows up most often in Python web apps. psycopg (no number, version 3) is the rewritten successor to psycopg2. New projects should start with psycopg. Existing projects on psycopg2 work fine and don't need an urgent rewrite; the APIs are similar but not identical. The examples below use psycopg (v3); the differences from psycopg2 are mostly in imports and a few method names.

For MySQL, mysql-connector-python is the official Oracle driver. PyMySQL is pure Python and easier to install on minimal systems. mysqlclient (a fork of the old MySQLdb) is the fastest because it's a C extension, but it needs the MySQL development headers at install time. Pick mysql-connector-python unless there is a specific reason not to.

Connection Strings and Connection Parameters

Every network driver needs the same five facts to find and authenticate against a server: host, port, user, password, database name. Each driver accepts them as a string or as keyword arguments, and most accept both forms.

For PostgreSQL with psycopg:

The three forms are equivalent. The URI form is the most portable: it's the same format that cloud providers, ORMs (SQLAlchemy), and CLI tools (psql) recognize. The libpq key-value form is closer to what PostgreSQL's own client library expects. The keyword-argument form is the most explicit when the values come from environment variables.

For MySQL with mysql-connector-python:

The key names differ slightly (PostgreSQL uses dbname, MySQL uses database), and the default port differs (5432 vs 3306). The shape is the same.

A URI like postgresql://app:s3cret@db.example.com:5432/shop reads cleanly but has one trap: special characters in the password (@, /, :, ?, #) need to be URL-encoded. urllib.parse.quote handles that:

Without quote, a @ in the password ends the user section early and the rest of the password becomes the host, which produces a baffling error message.

Secrets: Don't Hardcode Passwords

Connection examples in tutorials show the password as a string literal because the alternative is more code. In real applications, a password literal in source is a serious connection-related security mistake. Two reasons. First, the password gets committed to the repository and ends up in every clone, fork, and CI log forever. Second, every environment (dev, staging, production) uses the same password, which means a leak in one breaks all three.

The standard fix is to read credentials from environment variables (or from a secrets manager like AWS Secrets Manager, HashiCorp Vault, or Doppler). This is the "config in the environment" rule from the Twelve-Factor App methodology: configuration that varies between environments lives in the environment, not in the code.

os.environ["KEY"] raises KeyError if the variable isn't set, which is the right behavior for required config: fail at startup, not at the first query. os.environ.get("KEY", default) is for optional values with a sensible default (port numbers, timeouts).

For local development, the python-dotenv package is the convention. It loads variables from a .env file (which is git-ignored) into os.environ early in the program's lifetime.

A .env file for local dev might look like:

.env belongs in .gitignore; the actual production secrets belong in the platform's secret store (Kubernetes secrets, AWS Secrets Manager, Vault, a CI provider's encrypted variables). The Python code doesn't change; only the source of the environment variables does.

A single DATABASE_URL environment variable holding the full URI is a popular convention (Heroku, Railway, Render, Fly.io all use it). The Python code becomes psycopg.connect(os.environ["DATABASE_URL"]). SQLAlchemy and many ORMs accept the same URL format directly.

Closing Connections: try/finally and Context Managers

Every connection holds a real resource: a TCP socket, server-side memory, and (often) a slot in the database's connection limit. Code that opens connections without closing them eventually exhausts the limit, and the database refuses new ones with an error like "FATAL: too many connections."

The naive pattern is try/finally:

The finally block guarantees close() runs whether the query succeeded or raised. Forgetting finally is a common cause of "the app worked for a day and then stopped" bugs in database-backed services.

Most modern drivers support the connection itself as a context manager:

The exact semantics of with conn: differ slightly between drivers. In psycopg (v3), it closes the connection on exit and commits or rolls back the transaction (commit on success, rollback on exception). In psycopg2, with conn: only manages the transaction; the connection stays open until close() is called. Check the driver's docs to confirm.

For code that runs many short-lived units of work (a web request handler, for example), opening and closing a connection per unit is wasteful. Connection pooling solves this.

Connection Pooling

Opening a new TCP connection to PostgreSQL takes 5-50 ms even on a fast network. For a web service that handles 1000 requests per second, that overhead alone would dominate every request. A connection pool is a small cache of already-open connections that the application borrows from for each unit of work and returns when done.

The flow looks like this:

The pool maintains a small fixed set of open connections. Each unit of work checks one out, uses it, and returns it. The TCP and authentication cost is paid once at startup, not per request.

psycopg v3 includes a pool implementation in a separate package, psycopg_pool. The pattern is "open the pool at startup, borrow a connection inside each request, return it at the end."

min_size is how many connections the pool keeps warm. max_size is the hard cap. with pool.connection() as conn: borrows a connection, hands it to the block, and returns it on exit. The pool reuses it for the next caller.

A few rules of thumb that come up in every deployment:

  • The pool's max_size plus the number of application instances must stay below the database's connection limit. PostgreSQL defaults to 100 max connections, a server-wide cap.
  • Borrow a connection late and return it early. Holding a connection open while doing CPU work or talking to another service starves the pool.
  • Don't share one connection across threads or async tasks. The pool is thread-safe; the connection it returns usually isn't.

For high-throughput services, an external pooler like PgBouncer sits between the application and PostgreSQL and pools more aggressively (transaction-level pooling, where each transaction can use any backend connection). PgBouncer is configured outside Python and is invisible to the application except for a few SQL features that don't survive transaction-level pooling.

Timeouts and Retries

A connection that hangs is worse than a connection that fails. If a network blip leaves a connect() call waiting forever, the request that depends on it never completes. Two settings prevent that: a connect timeout (how long to wait for the initial connection) and a query timeout (how long to wait for a statement to finish).

For psycopg:

connect_timeout=5 means "fail with an exception after 5 seconds if the server hasn't accepted the connection." SET statement_timeout = 3000 is a PostgreSQL feature: any subsequent statement on the same connection that runs longer than 3000 milliseconds is killed and raises QueryCanceled. Other databases have similar features under different names (MySQL has max_execution_time hints).

Retries are the other half. A transient error (a network hiccup, a momentary server restart) doesn't mean the application has to crash. The pattern is to catch a specific exception, wait briefly, and try again, up to a small number of attempts.

OperationalError is the DB-API category for connection-level problems (network failures, authentication issues, server-side resource limits). Each driver subclasses it for more specific cases. The retry loop uses exponential backoff: wait 0.5 s, then 1 s, then 2 s. That avoids hammering a server that's already struggling.

Retries are not a free fix. They're appropriate for the connection step and for idempotent read queries. For writes that aren't idempotent (an INSERT without a uniqueness constraint, for example), a blind retry can produce duplicates. The safer pattern is to retry the whole transaction (which has a definite commit point) and to design writes so re-running them is safe.

A retry loop that waits forever turns a 3-second outage into an unbounded outage from the user's perspective. Cap the number of attempts and the total wait time. The pattern "try 3 times with exponential backoff, then give up" is a safe default for connection failures.

SSL/TLS: Encrypting the Connection

A connection between an application and a database carries passwords on login and the application's queries (often containing customer data) on every request. On a private network controlled end-to-end, plain TCP is sometimes acceptable. Across the public internet or a shared cloud network, the link should be encrypted with TLS.

PostgreSQL supports TLS through the sslmode parameter:

The values for sslmode, in increasing strictness:

sslmodeBehavior
disableNo TLS. Don't use outside a fully trusted network.
allowTry plain first, fall back to TLS if the server requires it.
preferTry TLS first, fall back to plain if the server doesn't support it. The default.
requireTLS only, but don't verify the server's certificate. Encrypts but doesn't prove identity.
verify-caTLS only, and verify the server's certificate against a known CA.
verify-fullTLS only, verify the certificate, and check the hostname matches. The strict default for production.

verify-full is the production default. It prevents man-in-the-middle attacks where an attacker presents a valid but unrelated certificate. The cost is that the driver needs the certificate authority bundle, usually via sslrootcert=/path/to/ca.crt in the connection string.

For MySQL, the equivalent settings live under ssl_ca, ssl_cert, and ssl_key keyword arguments to mysql.connector.connect. The shape is the same: pick a mode that verifies the server, point at the CA bundle, and the driver does the rest.

Managed cloud databases (RDS, Cloud SQL, Azure Database) usually provide their own CA bundle and document the exact parameters. Use what the provider documents; don't invent a custom TLS chain.

A Reusable Helper: One Function for Connection Setup

Pulling the patterns together: read config from the environment, set sane defaults, fail fast on missing required values, retry briefly on transient errors, and return a ready-to-use connection. Most applications end up with a helper close to this.

The helper reads DATABASE_URL from the environment, defaults the connect timeout to 5 seconds (overridable), retries connection failures with exponential backoff, and raises a clear error if every attempt fails. The application code stays small: open a connection, use a cursor, let the with blocks handle commit and close.

For pooled applications, the same helper opens the pool once at startup and the rest of the code borrows from it.

timeout here is how long a borrower waits for a free connection when the pool is full. Setting it lower than the overall request timeout causes a clear failure instead of requests piling up.

What Each Database's Connection Has in Common

The detail varies, but the connection patterns rhyme across drivers. The table below distills the parameters that matter most, mapped per database.

ConceptPostgreSQL (psycopg)MySQL (mysql-connector-python)SQL Server (pyodbc)
Database namedbnamedatabaseDATABASE in DSN
Default port543233061433
Placeholder%s (also %(name)s)%s (also %(name)s)?
Connect timeoutconnect_timeout (seconds)connection_timeout (seconds)Connection Timeout in DSN
TLSsslmode, sslrootcert, sslcert, sslkeyssl_ca, ssl_cert, ssl_keyEncrypt=yes;TrustServerCertificate=no
URI formpostgresql://user:pw@host:port/dbmysql://user:pw@host:port/db (via SQLAlchemy or third party)None natively; DSN string
Native pool in driverNo (use psycopg_pool)Yes (mysql.connector.pooling)No (use pyodbc.pooling flag)

Knowing the shape makes switching drivers possible without learning a new API end to end. The DB-API layer keeps execute, fetch, commit, and close consistent. The differences live in connect.

Quiz

Connecting to Databases Quiz

10 quizzes