Skip to content

Scaffold async database connection#3

Merged
podmar merged 2 commits into
mainfrom
feat/db-connection
May 29, 2026
Merged

Scaffold async database connection#3
podmar merged 2 commits into
mainfrom
feat/db-connection

Conversation

@podmar

@podmar podmar commented May 28, 2026

Copy link
Copy Markdown
Owner

Covers async DB connection layer.

Changes

  • app/database.py — async SQLAlchemy engine, session factory, and create_db_and_tables() startup hook
  • app/main.py — wired lifespan context manager to call create_db_and_tables() on startup
  • pyproject.toml — added sqlmodel, asyncpg, python-dotenv

Why these packages

  • SQLModel unifies SQLAlchemy (DB layer) and Pydantic (API layer) into one model class, avoiding duplicate schema definitions
  • asyncpg is the native async Postgres driver — the standard psycopg2 would block the event loop on every query
  • python-dotenv loads .env locally; on hosted platforms the env var comes from the platform directly and dotenv silently no-ops

Key patterns

  • One engine per process (connection pool, not per-request connections)
  • get_session as a FastAPI Depends — opens before the handler, guaranteed close/rollback after
  • expire_on_commit=False — keeps objects readable post-commit without a follow-up query (required in async, where lazy loading isn't available)
  • lifespan over the deprecated @app.on_event("startup")

Comment thread backend/app/database.py Outdated

load_dotenv()

DATABASE_URL = os.environ["DATABASE_URL"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Error handling: If DATABASE_URL is missing from the environment, this will raise a cryptic KeyError.

For a better developer experience, consider using .get() with a helpful error message:

Suggested change
DATABASE_URL = os.environ["DATABASE_URL"]
DATABASE_URL = os.environ.get("DATABASE_URL")
if not DATABASE_URL:
raise ValueError(
"DATABASE_URL environment variable is required. "
"See .env.example for setup instructions."
)

This gives a clearer error message that points developers to the solution.

Comment thread backend/app/database.py
_session_factory = async_sessionmaker(engine, expire_on_commit=False)


async def get_session() -> AsyncGenerator[AsyncSession]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Type annotation: The AsyncGenerator type needs two parameters: the yield type and the send type.

Suggested change
async def get_session() -> AsyncGenerator[AsyncSession]:
async def get_session() -> AsyncGenerator[AsyncSession, None]:

Why this matters: AsyncGenerator[AsyncSession, None] means:

  • Yields values of type AsyncSession
  • Accepts None when .send() is called (which FastAPI's dependency injection never does)

Pyright should catch this — if it doesn't, double-check your pyright config is active.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in Python 3.13 the None second argument is the default, so writing it out is redundant

Comment thread backend/app/database.py

engine = create_async_engine(DATABASE_URL, echo=False)

_session_factory = async_sessionmaker(engine, expire_on_commit=False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Learning note: expire_on_commit=False is important for async SQLAlchemy/SQLModel usage.

Why: By default, SQLAlchemy expires all objects after a commit, requiring a fresh query to access attributes. With async code, you can't lazily reload attributes after the session is closed (you'd need await). Setting this to False keeps objects usable after commit.

Tradeoff: You won't automatically see updates from other sessions, but that's fine for request-scoped sessions where each request gets a fresh session anyway.

Worth adding a brief comment here since it's a common async gotcha.

Comment thread backend/app/database.py Outdated

DATABASE_URL = os.environ["DATABASE_URL"]

engine = create_async_engine(DATABASE_URL, echo=False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Dev tip: echo=False is the right default for production, but during development you might want echo=True to see the actual SQL queries being executed.

You could make this configurable:

engine = create_async_engine(
    DATABASE_URL, 
    echo=os.environ.get("ENVIRONMENT") == "development"
)

Seeing the SQL is incredibly helpful when debugging query performance or checking that your ORM code generates what you expect.

Comment thread backend/app/main.py
app = FastAPI(lifespan=lifespan)


@app.get("/health")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Consistency: For consistency with the rest of the codebase (which uses async throughout), consider making this async def:

Suggested change
@app.get("/health")
@app.get("/health")
async def health() -> dict[str, str]:

Why: While FastAPI handles sync functions fine by running them in a thread pool, using async def everywhere:

  • Makes the async pattern consistent across all endpoints
  • Avoids any thread pool overhead (tiny but exists)
  • Signals to future developers that this is an async codebase

Not required, but a good habit in an async-first project.

@podmar
podmar merged commit 02097fb into main May 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant