Skip to main content

SQLModel + Async Engine Setup

Before your agent can read or write data, you need a database engine. The engine manages connections, handles pooling, and translates your Python code into SQL.

For async operations, you use create_async_engine from SQLAlchemy—and configure it for production reliability.

Installation

Install the async database stack:

pip install sqlmodel sqlalchemy[asyncio] asyncpg alembic

Output:

Successfully installed sqlmodel-0.0.22 sqlalchemy-2.0.35 asyncpg-0.29.0 alembic-1.13.1 greenlet-3.0.3

For local testing with SQLite:

pip install aiosqlite

What each package does:

PackagePurpose
sqlmodelORM combining Pydantic + SQLAlchemy
sqlalchemy[asyncio]Async engine and session support
asyncpgPostgreSQL async driver
alembicDatabase migrations
aiosqliteSQLite async driver (testing)

Creating the Async Engine

The engine is your connection to the database. Create it once at application startup:

from sqlalchemy.ext.asyncio import create_async_engine

DATABASE_URL = "postgresql+asyncpg://user:password@localhost:5432/taskdb"

engine = create_async_engine(
DATABASE_URL,
echo=True, # Log SQL statements (dev only)
)

Output (when using echo=True):

2024-01-15 10:30:00,123 INFO sqlalchemy.engine.Engine SELECT 1
2024-01-15 10:30:00,125 INFO sqlalchemy.engine.Engine [generated in 0.00012s] ()

URL Format: Sync to Async

Database URLs have a protocol prefix that specifies the driver. For async, you need async-compatible drivers:

Sync URLAsync URLDriver
postgresql://...postgresql+asyncpg://...asyncpg
sqlite:///./test.dbsqlite+aiosqlite:///./test.dbaiosqlite

Create a helper function to handle this conversion:

def get_async_database_url(url: str) -> str:
"""Convert sync database URL to async format."""
if url.startswith("postgresql://"):
return url.replace("postgresql://", "postgresql+asyncpg://", 1)
elif url.startswith("postgres://"):
# Heroku-style URLs
return url.replace("postgres://", "postgresql+asyncpg://", 1)
elif url.startswith("sqlite:///"):
return url.replace("sqlite:///", "sqlite+aiosqlite:///", 1)
return url # Already async or unknown format

Output:

>>> get_async_database_url("postgresql://user:pass@localhost/db")
'postgresql+asyncpg://user:pass@localhost/db'

Production Pooling Configuration

Connection pools maintain a set of database connections ready for use. Without pooling, every query opens a new connection—slow and resource-intensive.

engine = create_async_engine(
DATABASE_URL,
echo=settings.debug,
pool_size=5, # Maintain 5 connections
max_overflow=10, # Allow up to 15 total under load
pool_pre_ping=True, # Check connection health before use
pool_recycle=300, # Recreate connections every 5 minutes
)

What each parameter does:

ParameterValueWhy
pool_size5Base number of connections kept open
max_overflow10Extra connections allowed during spikes
pool_pre_pingTruePrevents "connection closed" errors
pool_recycle300Handles cloud DB connection timeouts

pool_pre_ping=True is essential. Managed databases (Neon, Supabase, RDS) close idle connections. Without pre-ping, your first query after idle time fails.

Connection Pooling Deep Dive

Understanding pools prevents production outages.

How Pooling Works

Application                    Connection Pool                   Database
│ │ │
├── Request 1 ─────────────────►│ [conn1: busy] │
│ │ [conn2: idle] ◄───────────────┤
├── Request 2 ─────────────────►│ [conn2: busy] │
│ │ [conn3: idle] │
│ Request 1 done ────────────►│ [conn1: idle] ◄── returned │
│ │ │

Lifecycle:

  1. Pool creates pool_size connections at startup
  2. When you request a session, pool gives you an idle connection
  3. When you're done, connection returns to pool (not closed)
  4. Under load, pool creates up to pool_size + max_overflow connections
  5. Overflow connections are closed when returned, not kept

Pool Exhaustion

When all connections are busy and overflow is maxed:

# This will hang waiting for a connection
async with AsyncSession(engine) as session:
await session.exec(select(Task)) # Blocked!

Symptoms:

  • Requests hang then timeout
  • Logs show: QueuePool limit of size 5 overflow 10 reached

Causes:

  1. Long-running transactions - Hold connections too long
  2. Forgotten sessions - Not closing sessions properly
  3. N+1 queries - Many sequential queries
  4. Pool too small - Undersized for traffic

Fixes:

# Set pool timeout to fail fast instead of hanging
engine = create_async_engine(
DATABASE_URL,
pool_pre_ping=True,
pool_size=5,
max_overflow=10,
pool_timeout=30, # Fail after 30s waiting for connection
)

Sizing Your Pool

Application Typepool_sizemax_overflowWhy
Low traffic API25Save resources
Standard API510Good default
High traffic API1020Handle concurrency
Background worker23Sequential processing

Rule of thumb: pool_size + max_overflow ≤ database max connections / number of app instances

External Pool Managers (PgBouncer)

For high-scale deployments, use external poolers:

Application ──► PgBouncer ──► PostgreSQL
(many conns) (pool mgr) (few conns)

When to use PgBouncer:

  • 100+ concurrent connections needed
  • Serverless (many cold starts)
  • Multiple applications sharing one database

Configuration with PgBouncer:

# Disable SQLAlchemy pooling when using PgBouncer
from sqlalchemy.pool import NullPool

engine = create_async_engine(
DATABASE_URL,
poolclass=NullPool, # Let PgBouncer handle pooling
)

Connection Security

Database credentials are high-value targets. Secure them properly.

Never Hardcode Credentials

# WRONG - credentials in code
DATABASE_URL = "postgresql+asyncpg://admin:supersecret@db.example.com/prod"

# RIGHT - environment variable
DATABASE_URL = os.getenv("DATABASE_URL")

Use .env for Local Development

# .env (add to .gitignore!)
DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/taskdb
# Load with python-dotenv
from dotenv import load_dotenv
load_dotenv()

DATABASE_URL = os.getenv("DATABASE_URL")

Secret Managers in Production

PlatformSecret Manager
AWSSecrets Manager, Parameter Store
GCPSecret Manager
AzureKey Vault
KubernetesSecrets (encrypted at rest)

Example with AWS Secrets Manager:

import boto3
import json

def get_database_url() -> str:
"""Fetch database URL from AWS Secrets Manager."""
client = boto3.client("secretsmanager")
response = client.get_secret_value(SecretId="prod/taskdb/credentials")
secret = json.loads(response["SecretString"])

return (
f"postgresql+asyncpg://{secret['username']}:{secret['password']}"
f"@{secret['host']}:{secret['port']}/{secret['database']}"
)

SSL/TLS for Database Connections

Always use SSL in production:

# PostgreSQL with SSL
engine = create_async_engine(
DATABASE_URL,
connect_args={
"ssl": "require", # Require SSL
# Or for self-signed certs:
# "ssl": ssl.create_default_context(cafile="path/to/ca.crt")
},
)

Verify SSL is working:

-- In PostgreSQL
SELECT ssl, version FROM pg_stat_ssl WHERE pid = pg_backend_pid();
-- Should show: ssl = true

SQLite for Testing

For local development and CI, use SQLite with async:

import os

if os.getenv("TESTING"):
DATABASE_URL = "sqlite+aiosqlite:///./test.db"
engine = create_async_engine(
DATABASE_URL,
echo=False,
connect_args={"check_same_thread": False},
)
else:
DATABASE_URL = get_async_database_url(os.getenv("DATABASE_URL"))
engine = create_async_engine(
DATABASE_URL,
pool_pre_ping=True,
pool_size=5,
)

Output (SQLite creation):

Created test.db (0 tables)

Why SQLite for testing?

  • No server required
  • Fast for unit tests
  • Compatible with most SQLModel features

Limitations:

  • No JSONB (use JSON instead for tests)
  • Different SQL dialect for some queries

Creating Tables

After defining models (next lesson), create tables:

from sqlmodel import SQLModel

async def create_db_and_tables() -> None:
"""Create all database tables."""
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)

Output:

INFO sqlalchemy.engine.Engine CREATE TABLE task (...)
INFO sqlalchemy.engine.Engine COMMIT

Call this at application startup:

from fastapi import FastAPI
from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app: FastAPI):
await create_db_and_tables()
yield
await engine.dispose()

app = FastAPI(lifespan=lifespan)

Complete Database Configuration

Here's a production-ready setup. Create database.py:

import os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from collections.abc import AsyncGenerator

def get_async_database_url(url: str) -> str:
"""Convert sync database URL to async format."""
if url.startswith("postgresql://"):
return url.replace("postgresql://", "postgresql+asyncpg://", 1)
elif url.startswith("postgres://"):
return url.replace("postgres://", "postgresql+asyncpg://", 1)
elif url.startswith("sqlite:///"):
return url.replace("sqlite:///", "sqlite+aiosqlite:///", 1)
return url

DATABASE_URL = get_async_database_url(
os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./dev.db")
)

engine = create_async_engine(
DATABASE_URL,
echo=os.getenv("DEBUG", "false").lower() == "true",
pool_pre_ping=True,
pool_size=5,
max_overflow=10,
)

async def create_db_and_tables() -> None:
"""Create all database tables."""
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)

async def get_session() -> AsyncGenerator[AsyncSession]:
"""Dependency that yields async database sessions."""
async with AsyncSession(engine) as session:
yield session

Output (on startup):

Database configured: postgresql+asyncpg://...
Pool size: 5, max overflow: 10
Tables created successfully

Try With AI

Prompt 1: Debug Connection Issues

I'm getting this error when my FastAPI app starts:
"asyncpg.exceptions.InvalidPasswordError: password authentication failed"

My DATABASE_URL is: postgresql://user:pass@localhost:5432/taskdb

What's wrong and how do I fix it?

What you're learning: URL format debugging—a common source of connection failures.

Prompt 2: Optimize for Cloud

I'm deploying to Railway with a managed PostgreSQL database.
My connections keep timing out after 5 minutes of inactivity.

Show me the engine configuration that handles this, and explain
each parameter.

What you're learning: Production pooling—configuring for managed database services.

Prompt 3: Environment-Based Configuration

I need different database configurations for:
- Development: SQLite, echo=True
- Testing: SQLite, echo=False
- Production: PostgreSQL with pooling

Show me how to structure database.py to handle all three
environments using environment variables.

What you're learning: Environment configuration—adapting database setup for different deployment contexts.

Safety Note

Never commit database credentials to version control. Use environment variables or secret managers. For local development, use .env files excluded from git.


Reflect on Your Skill

You built a relational-db-agent skill in Lesson 0. Test its engine configuration knowledge.

Test Your Skill

Using my relational-db-agent skill, generate a production-ready
database.py with:
- Async engine for PostgreSQL
- Connection pooling with pool_pre_ping
- URL conversion function
- get_session dependency for FastAPI

Identify Gaps

Ask yourself:

  • Did my skill include pool_pre_ping=True?
  • Did it handle the URL conversion correctly?
  • Did it structure get_session() with async with and yield?
  • Did it include pool_recycle for cloud databases?

Improve Your Skill

If you found gaps:

My relational-db-agent skill is missing pool_recycle configuration.
Update it to include pool_recycle=300 for managed database services
like Neon, Supabase, and Railway that close idle connections.

Your skill now generates cloud-ready database configurations.