FastAPI PostgreSQL Auth with Repository and Async SQLAlchemy
This setup creates a stronger FastAPI backend slice. It uses PostgreSQL in Docker Compose, async SQLAlchemy for database access, Pydantic schemas for API contracts, password hashing for authentication, JWT tokens for protected routes, and repository classes to keep database logic outside route handlers.
Project structure
Section titled “Project structure”Create a separate project folder for the PostgreSQL, authentication, repository, and async SQLAlchemy example. This example is a more complete backend structure than the SQLite CRUD setup.
03-postgres-auth-repository-async/ app/ __init__.py auth.py database.py main.py models.py repositories.py schemas.py requirements.txt Dockerfile compose.yamlmkdir 03-postgres-auth-repository-asynccd 03-postgres-auth-repository-asyncmkdir apptouch app/__init__.pyCreate the async database connection
Section titled “Create the async database connection”Create the database setup file at app/database.py. This file defines the async PostgreSQL engine, async session factory, Base class, and FastAPI database dependency.
app/database.pyimport os
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_enginefrom sqlalchemy.orm import DeclarativeBase
DATABASE_URL = os.getenv( "DATABASE_URL", "postgresql+asyncpg://app_user:app_password@db:5432/app_db",)
engine = create_async_engine( DATABASE_URL, echo=False,)
AsyncSessionLocal = async_sessionmaker( bind=engine, expire_on_commit=False,)
class Base(DeclarativeBase): pass
async def get_db(): async with AsyncSessionLocal() as session: yield sessionThe database host is db because Docker Compose services communicate through service names. Inside the backend container, localhost means the backend container itself, not the PostgreSQL container.
Create the SQLAlchemy models
Section titled “Create the SQLAlchemy models”Create the database models file at app/models.py. User stores login identity and password hash. Post belongs to a user through a foreign key relationship.
app/models.pyfrom __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Textfrom sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class User(Base): __tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False, index=True) hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
posts: Mapped[list["Post"]] = relationship( "Post", back_populates="author", cascade="all, delete-orphan", )
class Post(Base): __tablename__ = "posts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) title: Mapped[str] = mapped_column(String(100), nullable=False) content: Mapped[str] = mapped_column(Text, nullable=False) user_id: Mapped[int] = mapped_column( ForeignKey("users.id"), nullable=False, index=True, ) date_posted: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False, )
author: Mapped[User] = relationship( "User", back_populates="posts", )The database model includes hashed_password, but the response schemas do not expose it. This keeps the database structure separate from the API contract.
Create the Pydantic schemas
Section titled “Create the Pydantic schemas”Create the schema file at app/schemas.py. These schemas define registration input, user output, token output, post input, post update, and post output.
app/schemas.pyfrom datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class Token(BaseModel): access_token: str token_type: str
class UserCreate(BaseModel): username: str = Field(min_length=1, max_length=50) email: EmailStr = Field(max_length=120) password: str = Field(min_length=8, max_length=128)
class UserResponse(BaseModel): model_config = ConfigDict(from_attributes=True)
id: int username: str email: EmailStr
class PostCreate(BaseModel): title: str = Field(min_length=1, max_length=100) content: str = Field(min_length=1)
class PostUpdate(BaseModel): title: str | None = Field(default=None, min_length=1, max_length=100) content: str | None = Field(default=None, min_length=1)
class PostResponse(BaseModel): model_config = ConfigDict(from_attributes=True)
id: int title: str content: str user_id: int date_posted: datetime
class UserWithPosts(UserResponse): posts: list[PostResponse] = []UserCreate accepts a plain password only at the API boundary. The database stores hashed_password instead. UserResponse excludes password fields completely.
Create the repository layer
Section titled “Create the repository layer”Create the repository file at app/repositories.py. Repository classes keep SQLAlchemy query logic outside FastAPI route functions.
app/repositories.pyfrom sqlalchemy import or_, selectfrom sqlalchemy.ext.asyncio import AsyncSessionfrom sqlalchemy.orm import selectinload
from app import models
class UserRepository: def __init__(self, db: AsyncSession): self.db = db
async def get_by_id(self, user_id: int): result = await self.db.execute( select(models.User).where(models.User.id == user_id) )
return result.scalar_one_or_none()
async def get_with_posts(self, user_id: int): result = await self.db.execute( select(models.User) .options(selectinload(models.User.posts)) .where(models.User.id == user_id) )
return result.scalar_one_or_none()
async def get_by_username(self, username: str): result = await self.db.execute( select(models.User).where(models.User.username == username) )
return result.scalar_one_or_none()
async def get_by_username_or_email(self, username: str, email: str): result = await self.db.execute( select(models.User).where( or_( models.User.username == username, models.User.email == email, ) ) )
return result.scalar_one_or_none()
async def create(self, username: str, email: str, hashed_password: str): user = models.User( username=username, email=email, hashed_password=hashed_password, )
self.db.add(user) await self.db.commit() await self.db.refresh(user)
return user
class PostRepository: def __init__(self, db: AsyncSession): self.db = db
async def list_all(self): result = await self.db.execute( select(models.Post).order_by(models.Post.id) )
return result.scalars().all()
async def get_by_id(self, post_id: int): result = await self.db.execute( select(models.Post).where(models.Post.id == post_id) )
return result.scalar_one_or_none()
async def create(self, title: str, content: str, user_id: int): post = models.Post( title=title, content=content, user_id=user_id, )
self.db.add(post) await self.db.commit() await self.db.refresh(post)
return post
async def update(self, post, update_data): for field, value in update_data.items(): setattr(post, field, value)
await self.db.commit() await self.db.refresh(post)
return post
async def delete(self, post): await self.db.delete(post) await self.db.commit()The repository is useful here because auth, users, and posts need repeated database access. The route layer can focus on HTTP behavior while repositories handle query details.
Create the authentication utilities
Section titled “Create the authentication utilities”Create the authentication file at app/auth.py. This file hashes passwords, verifies login passwords, creates JWT tokens, and resolves the current authenticated user.
app/auth.pyimport osfrom datetime import UTC, datetime, timedeltafrom typing import Annotated
from fastapi import Depends, HTTPException, statusfrom fastapi.security import OAuth2PasswordBearerfrom jose import JWTError, jwtfrom passlib.context import CryptContextfrom sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_dbfrom app.repositories import UserRepository
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-change-me")ALGORITHM = "HS256"ACCESS_TOKEN_EXPIRE_MINUTES = 30
pwd_context = CryptContext( schemes=["bcrypt"], deprecated="auto",)
oauth2_scheme = OAuth2PasswordBearer( tokenUrl="auth/token",)
def verify_password(plain_password: str, hashed_password: str): return pwd_context.verify( plain_password, hashed_password, )
def get_password_hash(password: str): return pwd_context.hash(password)
def create_access_token(data: dict): to_encode = data.copy() expire = datetime.now(UTC) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update( { "exp": expire, } )
return jwt.encode( to_encode, SECRET_KEY, algorithm=ALGORITHM, )
async def get_current_user( token: Annotated[str, Depends(oauth2_scheme)], db: Annotated[AsyncSession, Depends(get_db)],): credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={ "WWW-Authenticate": "Bearer", }, )
try: payload = jwt.decode( token, SECRET_KEY, algorithms=[ALGORITHM], ) username = payload.get("sub")
if username is None: raise credentials_exception except JWTError as exc: raise credentials_exception from exc
repo = UserRepository(db) user = await repo.get_by_username(username)
if user is None: raise credentials_exception
return userThe token subject stores the username. Protected routes use get_current_user to decode the token and load the user from the database.
Create the FastAPI app
Section titled “Create the FastAPI app”Create the main application file at app/main.py. This file creates database tables on startup, registers users, logs users in, and protects post creation with JWT authentication.
app/main.pyfrom contextlib import asynccontextmanagerfrom typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Response, statusfrom fastapi.security import OAuth2PasswordRequestFormfrom sqlalchemy.ext.asyncio import AsyncSession
from app import modelsfrom app.auth import create_access_token, get_current_user, get_password_hash, verify_passwordfrom app.database import Base, engine, get_dbfrom app.repositories import PostRepository, UserRepositoryfrom app.schemas import ( PostCreate, PostResponse, PostUpdate, Token, UserCreate, UserResponse, UserWithPosts,)
@asynccontextmanagerasync def lifespan(_app): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all)
yield
app = FastAPI( title="03 PostgreSQL Auth Repository Async", lifespan=lifespan,)
@app.get("/")def home(): return { "message": "FastAPI PostgreSQL auth repository async example" }
@app.post( "/auth/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED,)async def register( payload: UserCreate, db: Annotated[AsyncSession, Depends(get_db)],): repo = UserRepository(db)
existing_user = await repo.get_by_username_or_email( payload.username, payload.email, )
if existing_user: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Username or email already exists", )
hashed_password = get_password_hash(payload.password)
user = await repo.create( username=payload.username, email=payload.email, hashed_password=hashed_password, )
return user
@app.post("/auth/token", response_model=Token)async def login( form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: Annotated[AsyncSession, Depends(get_db)],): repo = UserRepository(db) user = await repo.get_by_username(form_data.username)
if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", )
if not verify_password(form_data.password, user.hashed_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", )
access_token = create_access_token( data={ "sub": user.username, } )
return { "access_token": access_token, "token_type": "bearer", }
@app.get("/users/me", response_model=UserResponse)async def read_current_user( current_user: Annotated[models.User, Depends(get_current_user)],): return current_user
@app.get("/users/{user_id}", response_model=UserWithPosts)async def read_user( user_id: int, db: Annotated[AsyncSession, Depends(get_db)],): repo = UserRepository(db) user = await repo.get_with_posts(user_id)
if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found", )
return user
@app.post( "/posts", response_model=PostResponse, status_code=status.HTTP_201_CREATED,)async def create_post( payload: PostCreate, current_user: Annotated[models.User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)],): repo = PostRepository(db)
post = await repo.create( title=payload.title, content=payload.content, user_id=current_user.id, )
return post
@app.get("/posts", response_model=list[PostResponse])async def list_posts( db: Annotated[AsyncSession, Depends(get_db)],): repo = PostRepository(db)
return await repo.list_all()
@app.get("/posts/{post_id}", response_model=PostResponse)async def read_post( post_id: int, db: Annotated[AsyncSession, Depends(get_db)],): repo = PostRepository(db) post = await repo.get_by_id(post_id)
if not post: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Post not found", )
return post
@app.patch("/posts/{post_id}", response_model=PostResponse)async def update_post( post_id: int, payload: PostUpdate, current_user: Annotated[models.User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)],): repo = PostRepository(db) post = await repo.get_by_id(post_id)
if not post: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Post not found", )
if post.user_id != current_user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can update this post", )
update_data = payload.model_dump(exclude_unset=True)
return await repo.update( post, update_data, )
@app.delete("/posts/{post_id}", status_code=status.HTTP_204_NO_CONTENT)async def delete_post( post_id: int, current_user: Annotated[models.User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)],): repo = PostRepository(db) post = await repo.get_by_id(post_id)
if not post: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Post not found", )
if post.user_id != current_user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can delete this post", )
await repo.delete(post)
return Response(status_code=status.HTTP_204_NO_CONTENT)Add dependencies
Section titled “Add dependencies”Create the dependency file at requirements.txt. asyncpg is the async PostgreSQL driver. python-multipart is required for OAuth2PasswordRequestForm in the login route.
requirements.txtfastapiuvicorn[standard]sqlalchemyasyncpgemail-validatorpasslib[bcrypt]python-jose[cryptography]python-multipartCreate the Dockerfile
Section titled “Create the Dockerfile”Create the Dockerfile at the project root. The container owns the Python environment and runs the FastAPI app with Uvicorn.
DockerfileFROM python:3.12-slim
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"ENV PYTHONDONTWRITEBYTECODE=1ENV PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --upgrade pipRUN pip install -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]Create the Compose file
Section titled “Create the Compose file”Create the Docker Compose file at compose.yaml. This runs the FastAPI backend and PostgreSQL database together. PostgreSQL data is stored in a named Docker volume.
compose.yamlservices: backend: build: . container_name: fastapi-03-postgres-auth-repository-async ports: - "8000:8000" environment: DATABASE_URL: postgresql+asyncpg://app_user:app_password@db:5432/app_db SECRET_KEY: change-this-dev-secret volumes: - .:/app depends_on: - db
db: image: postgres:16-alpine container_name: fastapi-03-postgres-db environment: POSTGRES_USER: app_user POSTGRES_PASSWORD: app_password POSTGRES_DB: app_db ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data
volumes: postgres_data:Run the project
Section titled “Run the project”Run Docker Compose from inside the project folder. The backend connects to PostgreSQL through the db service name and creates the tables during FastAPI startup.
docker compose up --buildOpen the API docs page to test registration, login, and protected routes.
http://localhost:8000/docsRegister and login
Section titled “Register and login”Create a user through POST /auth/register. The password is accepted in the request but only the hashed password is stored in the database.
{ "username": "baris", "password": "strongpassword123"}Log in through POST /auth/token. In the Swagger UI, use username and password form fields. The response returns a bearer token.
{ "access_token": "jwt-token-value", "token_type": "bearer"}Use protected routes
Section titled “Use protected routes”Use the Authorize button in the Swagger UI and paste the access token. Then protected routes can resolve the current user from the token.
GET /users/mePOST /postsPATCH /posts/1DELETE /posts/1Create a post through POST /posts. The backend assigns user_id from the authenticated user instead of accepting it from the client.
{ "title": "PostgreSQL Auth Example", "content": "This post belongs to the authenticated user."}Repository pattern role
Section titled “Repository pattern role”The route layer handles HTTP behavior such as status codes, authentication, and validation errors. The repository layer handles SQLAlchemy queries, persistence, updates, and deletes.
Route function handles request and response behavior
Repository handles database query logic
AsyncSession works as the database unit of work
SQLAlchemy model defines table structure
Pydantic schema defines API input and outputAsync SQLAlchemy role
Section titled “Async SQLAlchemy role”Async SQLAlchemy uses await for database operations. This lets the FastAPI server continue handling other work while waiting for PostgreSQL.
result = await db.execute( select(models.User).where(models.User.username == username))
user = result.scalar_one_or_none()Use async database access when the application is I/O heavy, uses async libraries, or needs to handle many concurrent requests.
Inspect PostgreSQL data
Section titled “Inspect PostgreSQL data”Open a psql shell inside the PostgreSQL container to inspect the created tables and records.
docker compose exec db psql -U app_user -d app_db\dtselect id, username, email from users;select id, title, user_id from posts;Stop or reset the project
Section titled “Stop or reset the project”Stop the running containers without deleting PostgreSQL data.
docker compose downReset the project by removing the PostgreSQL volume. This deletes the database records.
docker compose down -v