Build a Job Application Tracker API

Build a REST API with FastAPI and PostgreSQL to track job applications.

Introduction

30 Second Summary

Job hunting involves tracking dozens of applications across companies, positions, and statuses. Spreadsheets work at first, but they can't power a real application or teach you how professional backends are built.

In this project, you will build a REST API that stores and manages job application data in a PostgreSQL database. You'll use FastAPI, SQLAlchemy, and Pydantic to create a fully functional backend service with create, read, update, and delete operations.

What You'll Build

A Job Application Tracker API with full CRUD endpoints backed by a PostgreSQL database, with interactive documentation via Swagger UI.

By the end of this project, you'll have:

  • A FastAPI application with a modular project structure connected to a PostgreSQL database.
  • SQLAlchemy ORM models that map Python classes to database tables, with Pydantic schemas for automatic request validation and response serialization.
  • Full CRUD endpoints (Create, Read, Update, Delete) with status filtering and interactive Swagger UI documentation.
  • Secret Mission: Add a statistics endpoint that summarizes your applications by status and average salary.

Are there any prerequisites?

You'll need basic Python knowledge (variables, functions, classes) and familiarity with what an API is. No prior experience with FastAPI, databases, or PostgreSQL is required.

Install PostgreSQL with Postgres.app

Your Job Application Tracker needs a database to store all the job applications you create through your API. PostgreSQL is one of the most popular relational databases in professional backend development.

In this step, you will install PostgreSQL using Postgres.app and create a dedicated database for your project.

In this step, get ready to:
  • Install and start PostgreSQL using Postgres.app.
  • Add PostgreSQL command-line tools to your PATH.
  • Create the project database.
Download and install Postgres.app

Postgres.app is a native macOS app that runs PostgreSQL without a traditional installer. You open the app and it starts a local PostgreSQL server automatically.

  • Open your browser and go to https://postgresapp.com.
  • Click Download to download the latest version.
  • Open the downloaded .dmg file and drag Postgres.app into your Applications folder.
  • Open Postgres.app from your Applications folder.
  • Click Initialize to start the PostgreSQL server.

What is Postgres.app doing?

When you click Initialize, Postgres.app creates a local PostgreSQL server on your machine.

It connects via localhost with your macOS username, so no password is needed for local connections. The server starts automatically whenever Postgres.app is open.

  • Confirm you see a green checkmark or a running server indicator in the Postgres.app window.

Don't see the Initialize button?

If you see a list of databases instead of an Initialize button, your server is already running from a previous installation. You are good to move on.

If the app does not open at all, make sure you dragged it into your Applications folder first. Help me troubleshoot Postgres.app not opening.

Add Postgres CLI tools to your PATH

Postgres.app includes command-line tools like psql and createdb, but your terminal does not know where to find them yet. Adding them to your PATH lets you use these tools from any terminal window.

  • Open Terminal by pressing Cmd+Space (macOS) to open your search bar, typing Terminal, and pressing Enter.
  • Add the Postgres.app CLI tools to your PATH by running this command:
sudo mkdir -p /etc/paths.d && echo /Applications/Postgres.app/Contents/Versions/latest/bin | sudo tee /etc/paths.d/postgresapp

What does this command do?

This command creates a file at /etc/paths.d/postgresapp that tells macOS where to find the PostgreSQL tools.

The sudo prefix is needed because /etc/paths.d/ is a system directory that requires administrator access. Your Mac will prompt you for your password.

  • Enter your Mac password when prompted. You will not see characters appear as you type. This is normal.
  • Close this terminal window completely.
  • Open a new terminal window (Cmd+Space, type Terminal, press Enter).
  • Verify the PATH is working by running:
psql --version

You should see output like psql (PostgreSQL) 17.x (the exact version depends on your Postgres.app download).

Seeing 'command not found'?

Make sure you closed the old terminal and opened a brand new one. The PATH change only takes effect in new terminal windows.

If it still does not work, double-check that Postgres.app is in your /Applications folder (not in Downloads or on the Desktop). Help me fix psql command not found after setting up Postgres.app PATH.

Create the project database

PostgreSQL stores data in separate databases. You will create a dedicated database named job_tracker for this project.

  • Create the database by running this command in your terminal:
createdb job_tracker

What does this command do?

The createdb command is a PostgreSQL utility that creates a new empty database on your local server. Your API will connect to this job_tracker database to store and retrieve job application records.

  • Verify the database was created by running:
psql job_tracker -c '\dt'

You should see a message saying Did not find any relations. because the database is empty. This confirms the database exists and you can connect to it.

Seeing a connection error?

Make sure Postgres.app is still open and showing the green running indicator. The server must be running for createdb and psql to connect.

If you see a role does not exist error, your macOS username may not match the default PostgreSQL user. Help me fix this PostgreSQL role error.

Your PostgreSQL server is up and your project database is ready. Next up, you will set up the Python project structure and install the libraries that connect your API to this database.

Set Up the Project and Install Dependencies

Your PostgreSQL database is ready and waiting for connections. Now you need a Python project that can actually talk to it.

In this step, you'll create a modular project structure, install the Python packages your API needs, and configure the database connection string.

In this step, get ready to:
  • Create the project folder and file structure.
  • Set up a virtual environment and install Python packages.
  • Configure the database connection string.
Create the project directory structure

A well-organized project separates different concerns into their own files. You'll create a folder structure that keeps your database logic, data models, API routes, and configuration each in their own place.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder, subfolders, and empty files by running these commands:
mkdir job-tracker-api
cd job-tracker-api
mkdir -p app/routers
touch requirements.txt app/__init__.py app/main.py app/database.py app/models.py app/schemas.py app/config.py app/routers/__init__.py app/routers/applications.py

What do these commands do?

  • mkdir job-tracker-api creates your project folder.
  • mkdir -p app/routers creates the app/ folder and a routers/ subfolder inside it. The -p flag creates parent directories as needed.
  • touch creates empty files. Each one will hold a specific piece of your API later.
  • Confirm the files exist by running:
ls
ls app/
ls app/routers/

You should see requirements.txt in the top level, __init__.py, main.py, database.py, models.py, schemas.py, and config.py inside app/. You should see __init__.py and applications.py inside app/routers/.

Missing files or folders?

Double check that you ran cd job-tracker-api before running the mkdir and touch commands. If you're not inside the project folder, the files end up in the wrong location.

Help me fix my directory structure

  • Open VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the job-tracker-api folder, then click Open.

You should see your project structure in the Explorer sidebar on the left, with the app folder and requirements.txt listed.

  • Open requirements.txt by clicking on it in the Explorer sidebar.
  • Add the following package list to requirements.txt:
fastapi[standard]==0.136.3
sqlalchemy==2.0.50
psycopg2-binary==2.9.12
pydantic-settings==2.14.1

What are these packages?

  • fastapi[standard] is the web framework for building your API. The [standard] extra includes uvicorn (the server that runs your app).
  • sqlalchemy is the ORM that lets you interact with your database using Python classes instead of raw SQL.
  • psycopg2-binary is the PostgreSQL driver that SQLAlchemy uses to communicate with your database.
  • pydantic-settings loads configuration from environment variables and .env files.
  • Save requirements.txt by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you've saved requirements.txt and move on to the next substep.

ⓧ I'd like to double check the full code

Your requirements.txt should look exactly like this:

fastapi[standard]==0.136.3
sqlalchemy==2.0.50
psycopg2-binary==2.9.12
pydantic-settings==2.14.1
Create a virtual environment and install packages

A virtual environment is an isolated space for your project's Python packages. It keeps this project's dependencies separate from other Python projects on your machine.

  • Open VS Code's built-in terminal by pressing Ctrl+` (backtick key, below Escape).
  • Make sure you're inside the job-tracker-api folder. If not, run:
cd ~/Desktop/job-tracker-api
  • Create the virtual environment by running:
python3 -m venv venv
  • Activate the virtual environment by running:
source venv/bin/activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

Make sure you typed source venv/bin/activate exactly. If you see "No such file or directory", the python3 -m venv venv command may not have run successfully. Try running it again from inside your job-tracker-api folder.

Help me troubleshoot my virtual environment

  • Install all four packages by running:
pip install -r requirements.txt

You should see output ending with "Successfully installed" followed by the package names. This may take 30-60 seconds.

Installation failed?

Make sure (venv) appears in your terminal prompt before running pip install. Installing without an active virtual environment can cause permission errors.

If you see a network error, check your internet connection and try running the command again.

Help me fix my package installation

Add the database connection string

Your API needs to know where to find the PostgreSQL database. You'll store this as an environment variable in a .env file, which keeps configuration separate from your code.

  • In VS Code's Explorer sidebar, click the new file icon next to JOB-TRACKER-API (the project root) to create a new file.
  • Name the file .env and press Enter.
  • Add the database connection string to .env:
DATABASE_URL=postgresql://localhost/job_tracker

What does this connection string mean?

postgresql://localhost/job_tracker tells SQLAlchemy to connect to a PostgreSQL database called job_tracker running on your local machine. Because Postgres.app uses your OS username for authentication by default, no password is needed for local connections.

  • Save .env by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you've saved .env and you're ready for the next step.

ⓧ I'd like to double check the full code

Your .env should contain exactly this:

DATABASE_URL=postgresql://localhost/job_tracker

Your project skeleton is in place with all dependencies installed. Next up, you'll write the code that connects your API to the PostgreSQL database.

Configure the Database Connection

Your project structure is in place and all your dependencies are installed. Now it's time to connect your application to the PostgreSQL database you created earlier.

Connecting to a database involves two concerns: loading your connection credentials safely, and creating the machinery that opens and closes database sessions. You'll handle both in this step using Pydantic Settings for configuration and SQLAlchemy for the database layer.

In this step, get ready to:
  • Set up Pydantic Settings to load environment variables from your .env file.
  • Configure SQLAlchemy's engine, session factory, and base class.
  • Verify the database connection works by importing your modules.
Set up Pydantic Settings in config.py

Your app needs the database URL to connect to PostgreSQL. Instead of hardcoding it, you'll use pydantic-settings to load it from the .env file automatically.

  • Open app/config.py in VS Code and add the following code:
from pydantic_settings import BaseSettings


# Load environment variables into a typed Python class
class Settings(BaseSettings):
    DATABASE_URL: str

    # Tell pydantic-settings to read from the .env file
    model_config = {"env_file": ".env"}


# Create a single instance the rest of the app imports
settings = Settings()

What does this code do?

The Settings class inherits from BaseSettings. This means it automatically reads environment variables and validates them against the type hints you define.

The model_config = {"env_file": ".env"} line tells Pydantic to look for a .env file in your project root. When Settings() is instantiated, it reads DATABASE_URL from that file and makes it available as settings.DATABASE_URL.

  • Save app/config.py.

Why load config from a .env file?

Keeping your database URL in a .env file separates configuration from code. You can change the database URL without editing any Python files. In production, you'd swap the .env for real environment variables set on the server.

✔️ Awesome, I've got everything!

Great. Double check you've saved app/config.py before moving on.

ⓧ I'd like to double check the full code

from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    DATABASE_URL: str

    model_config = {"env_file": ".env"}


settings = Settings()
Configure SQLAlchemy engine and session

With your config ready, you can now set up the database layer. SQLAlchemy is an ORM (Object-Relational Mapper) that lets you work with database tables as Python classes instead of writing raw SQL.

  • Open app/database.py and add the imports, engine, session factory, and base class:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase

from app.config import settings

# Create the connection to PostgreSQL
engine = create_engine(settings.DATABASE_URL)

# Factory for creating new database sessions
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)


# Base class that all database models will inherit from
class Base(DeclarativeBase):
    pass

What does this code do?

  • create_engine(settings.DATABASE_URL) creates a connection pool to your PostgreSQL database using the URL from your .env file.
  • SessionLocal is a factory that creates new database sessions. Each session is a conversation with the database that you open, use, and close.
  • Base is the parent class for all your database models. Every table you define will inherit from it.

You won't see a change yet. The next chunk uses this setup to provide database sessions to your API endpoints.

  • Below the Base class in app/database.py, add the get_db dependency function:
# Provide a database session to each API request
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

What is dependency injection?

The get_db function is a dependency injection pattern used by FastAPI. When an endpoint needs a database session, FastAPI calls get_db automatically, provides the session, and closes it when the request finishes.

The yield keyword pauses the function, hands the session to the endpoint, then resumes after the endpoint returns to run db.close(). This guarantees every session gets properly cleaned up.

  • Save app/database.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved app/database.py before moving on.

ⓧ I'd like to double check the full code

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase

from app.config import settings

engine = create_engine(settings.DATABASE_URL)

SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)


class Base(DeclarativeBase):
    pass


def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()
Verify the database connection

Before moving on, confirm that your config and database modules load correctly and can reach PostgreSQL.

  • In your terminal, verify the configuration loads by running this command:
python -c "from app.database import engine; print(engine.url)"
  • You should see postgresql://localhost/job_tracker printed in the terminal.

Seeing a ModuleNotFoundError?

  • Make sure you are running the command from your project root directory (the folder containing the app/ folder).
  • Confirm your virtual environment is active. You should see (venv) at the start of your terminal prompt.
  • If you see an error about pydantic_settings, re-run pip install -r requirements.txt to install missing packages.

Still stuck? Help me fix a ModuleNotFoundError when importing app.database

Your database layer is wired up. Next, you'll define the database model that represents a job application and the Pydantic schemas that validate incoming data.

Define the Database Model and Schemas

Your database connection is live and your app starts cleanly. Now it's time to define what your data actually looks like.

In this step, you'll create two layers: a SQLAlchemy model that maps a Python class to a PostgreSQL table, and Pydantic schemas that control what data goes in and out of your API. These two layers work together to keep your database safe and your API responses predictable.

In this step, get ready to:
  • Create a SQLAlchemy model that defines the job_applications table.
  • Create Pydantic schemas for request validation and response serialization.
Create the JobApplication SQLAlchemy model

An ORM (Object-Relational Mapping) lets you define database tables as Python classes. Each column becomes a class attribute with a declared type.

  • Define the JobApplication model by pasting the following code into app/models.py:
from datetime import datetime, date
from sqlalchemy import String, Integer, Date, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column

from app.database import Base


class JobApplication(Base):
    __tablename__ = "job_applications"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
    company: Mapped[str] = mapped_column(String(200), nullable=False)
    position: Mapped[str] = mapped_column(String(200), nullable=False)
    status: Mapped[str] = mapped_column(String(50), default="applied")
    date_applied: Mapped[date] = mapped_column(Date, nullable=False)
    salary: Mapped[int | None] = mapped_column(Integer, nullable=True)
    notes: Mapped[str | None] = mapped_column(String(1000), nullable=True)
    created_at: Mapped[datetime] = mapped_column(
        DateTime, server_default=func.now()
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime, server_default=func.now(), onupdate=func.now()
    )

What does this code do?

This class maps directly to a PostgreSQL table called job_applications. SQLAlchemy's Mapped type annotations tell Python and SQLAlchemy what each column holds.

  • Mapped[int | None] means the column can be None (nullable). Mapped[str] means it is required.
  • server_default=func.now() tells PostgreSQL to generate the timestamp on the database side, so you never have to set it manually.
  • onupdate=func.now() automatically refreshes updated_at whenever you modify a row.
  • Save app/models.py.
  • Verify the model loads correctly by running this in your terminal:
python -c "from app.models import JobApplication; print('Model loaded successfully')"

You should see Model loaded successfully printed in your terminal.

Seeing an ImportError?

  • Make sure you are running the command from your project root folder (the one containing the app/ directory).
  • Check that from app.database import Base in your model matches the exact class name Base in app/database.py.
  • Confirm your virtual environment is still active. You should see (venv) at the start of your terminal prompt.

Help me debug my SQLAlchemy model import error.

✔️ Awesome, I've got everything!

Great. Make sure you've saved app/models.py before moving on.

ⓧ I'd like to double check the full code

from datetime import datetime, date
from sqlalchemy import String, Integer, Date, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column

from app.database import Base


class JobApplication(Base):
    __tablename__ = "job_applications"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
    company: Mapped[str] = mapped_column(String(200), nullable=False)
    position: Mapped[str] = mapped_column(String(200), nullable=False)
    status: Mapped[str] = mapped_column(String(50), default="applied")
    date_applied: Mapped[date] = mapped_column(Date, nullable=False)
    salary: Mapped[int | None] = mapped_column(Integer, nullable=True)
    notes: Mapped[str | None] = mapped_column(String(1000), nullable=True)
    created_at: Mapped[datetime] = mapped_column(
        DateTime, server_default=func.now()
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime, server_default=func.now(), onupdate=func.now()
    )
Create Pydantic schemas for request and response validation

The SQLAlchemy model describes how data is stored in the database. Pydantic schemas describe how data enters and leaves your API. They validate incoming request bodies and control exactly which fields appear in responses.

Why separate models and schemas?

Your database model has fields the user should never set directly (like id, created_at). Schemas let you expose only the fields that make sense for each operation. A "create" schema requires certain fields, an "update" schema makes everything optional, and a "response" schema shows the full record.

  • Add the request schemas to app/schemas.py by pasting the following code:
from datetime import date, datetime
from pydantic import BaseModel


# Schema for creating a new application
class JobApplicationCreate(BaseModel):
    company: str
    position: str
    status: str = "applied"
    date_applied: date
    salary: int | None = None
    notes: str | None = None


# Schema for updating an existing application (all fields optional)
class JobApplicationUpdate(BaseModel):
    company: str | None = None
    position: str | None = None
    status: str | None = None
    date_applied: date | None = None
    salary: int | None = None
    notes: str | None = None

What do these schemas do?

  • JobApplicationCreate requires company, position, and date_applied when a user submits a new application. The other fields are optional with sensible defaults.
  • JobApplicationUpdate makes every field optional. This lets users update only the fields they want to change without resending the entire record.
  • Save app/schemas.py.
  • You won't see a visible change yet. The next chunk adds the response schema that completes this file.
  • Add the response schema below the code you just pasted in app/schemas.py:
# Schema for API responses (includes all database-generated fields)
class JobApplicationResponse(BaseModel):
    id: int
    company: str
    position: str
    status: str
    date_applied: date
    salary: int | None
    notes: str | None
    created_at: datetime
    updated_at: datetime

    model_config = {"from_attributes": True}

What does from_attributes do?

By default, Pydantic expects dictionaries. SQLAlchemy returns objects with attributes (like application.company instead of application["company"]). Setting from_attributes = True tells Pydantic to read data from object attributes, which is what SQLAlchemy models provide.

  • Save app/schemas.py.
  • Verify all three schemas load correctly by running:
python -c "from app.schemas import JobApplicationCreate, JobApplicationUpdate, JobApplicationResponse; print('Schemas loaded successfully')"

You should see Schemas loaded successfully printed in your terminal.

Seeing a NameError or ImportError?

  • Check that from datetime import date, datetime is at the very top of app/schemas.py. Both date and datetime need to be imported.
  • Make sure you have from pydantic import BaseModel and not from pydantic_settings import BaseModel. These are two different packages.

Help me fix my Pydantic schema import error.

✔️ Awesome, I've got everything!

Great. Make sure you've saved app/schemas.py before moving on.

ⓧ I'd like to double check the full code

from datetime import date, datetime
from pydantic import BaseModel


class JobApplicationCreate(BaseModel):
    company: str
    position: str
    status: str = "applied"
    date_applied: date
    salary: int | None = None
    notes: str | None = None


class JobApplicationUpdate(BaseModel):
    company: str | None = None
    position: str | None = None
    status: str | None = None
    date_applied: date | None = None
    salary: int | None = None
    notes: str | None = None


class JobApplicationResponse(BaseModel):
    id: int
    company: str
    position: str
    status: str
    date_applied: date
    salary: int | None
    notes: str | None
    created_at: datetime
    updated_at: datetime

    model_config = {"from_attributes": True}

Your data layer is fully defined. You have a model that tells PostgreSQL how to store job applications, and schemas that control what your API accepts and returns. Next up, you'll build the actual API endpoints that bring everything together.

Build the CRUD API Endpoints

You have a database model and validation schemas ready. Now it is time to build the actual API endpoints that let you create, read, update, and delete job applications.

In this step, you will write the router that handles all incoming requests, wire it into the main application file, and test every endpoint through FastAPI's interactive documentation.

In this step, get ready to:
  • Implement POST and GET endpoints for creating and listing applications.
  • Implement GET, PUT, and DELETE endpoints for individual applications.
  • Wire the router into the main app and test all endpoints in Swagger UI.
Implement the POST and GET list endpoints

The router is where you define what happens when someone sends an HTTP request to your API. Each endpoint maps a URL path and HTTP method to a Python function.

  • In VS Code, open app/routers/applications.py and add the imports, router setup, and your first two endpoints by pasting this code:
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session

from app.database import get_db
from app.models import JobApplication
from app.schemas import (
    JobApplicationCreate,
    JobApplicationUpdate,
    JobApplicationResponse,
)

# Create a router that groups all application endpoints under /applications
router = APIRouter(prefix="/applications", tags=["applications"])


@router.post("/", response_model=JobApplicationResponse, status_code=201)
def create_application(
    application: JobApplicationCreate, db: Session = Depends(get_db)
):
    # Convert the Pydantic schema to a SQLAlchemy model instance
    db_application = JobApplication(**application.model_dump())
    db.add(db_application)
    db.commit()
    db.refresh(db_application)
    return db_application


@router.get("/", response_model=list[JobApplicationResponse])
def list_applications(status: str | None = None, db: Session = Depends(get_db)):
    query = db.query(JobApplication)
    # Apply optional filter if a status query parameter is provided
    if status:
        query = query.filter(JobApplication.status == status)
    return query.order_by(JobApplication.date_applied.desc()).all()

What does this code do?

APIRouter groups related endpoints under a shared URL prefix. Every endpoint you add to this router will start with /applications.

The create_application function uses Depends(get_db) to get a database session automatically injected by FastAPI. It converts the incoming Pydantic data into a SQLAlchemy object, saves it, and returns the result.

The list_applications function accepts an optional status query parameter. If provided, it filters results to only show applications with that status.

What is dependency injection?

When you write db: Session = Depends(get_db), you are telling FastAPI to call get_db() before your function runs and pass the result as the db argument. FastAPI also handles closing the session when the request finishes.

This means your endpoint functions never worry about opening or closing database connections. FastAPI manages the lifecycle for you.

  • Save app/routers/applications.py.
  • You will not see a change yet. The next substep adds the remaining endpoints, and then you will wire everything together and test.
Implement the single-item endpoints

The collection endpoints handle creating and listing. Now you need endpoints that operate on a single application by its ID.

  • In app/routers/applications.py, add the following three endpoints below the list_applications function:
@router.get("/{application_id}", response_model=JobApplicationResponse)
def get_application(application_id: int, db: Session = Depends(get_db)):
    # Look up a single application by its primary key
    application = db.query(JobApplication).filter(
        JobApplication.id == application_id
    ).first()
    if not application:
        raise HTTPException(status_code=404, detail="Application not found")
    return application


@router.put("/{application_id}", response_model=JobApplicationResponse)
def update_application(
    application_id: int,
    updates: JobApplicationUpdate,
    db: Session = Depends(get_db),
):
    application = db.query(JobApplication).filter(
        JobApplication.id == application_id
    ).first()
    if not application:
        raise HTTPException(status_code=404, detail="Application not found")

    # Only update fields that were actually sent in the request
    update_data = updates.model_dump(exclude_unset=True)
    for field, value in update_data.items():
        setattr(application, field, value)

    db.commit()
    db.refresh(application)
    return application


@router.delete("/{application_id}", status_code=204)
def delete_application(application_id: int, db: Session = Depends(get_db)):
    application = db.query(JobApplication).filter(
        JobApplication.id == application_id
    ).first()
    if not application:
        raise HTTPException(status_code=404, detail="Application not found")

    db.delete(application)
    db.commit()

What does this code do?

get_application fetches a single application by ID. If the ID does not exist, it raises a 404 error that FastAPI converts into a JSON response automatically.

update_application uses exclude_unset=True so only the fields the client actually sent get updated. This means you can update just the status without touching any other field.

delete_application removes the record and returns a 204 (No Content) status code with no response body.

  • Save app/routers/applications.py.

Seeing a red underline on an import?

If your editor shows an error on from app.database import get_db, make sure your virtual environment is selected as the Python interpreter in VS Code. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type Python: Select Interpreter, and choose the one inside your venv folder.

help me fix import errors in my FastAPI project

✔️ Awesome, I've got everything!

Great. Double check you have saved app/routers/applications.py with all five endpoint functions.

ⓧ I'd like to double check the full code

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session

from app.database import get_db
from app.models import JobApplication
from app.schemas import (
    JobApplicationCreate,
    JobApplicationUpdate,
    JobApplicationResponse,
)

router = APIRouter(prefix="/applications", tags=["applications"])


@router.post("/", response_model=JobApplicationResponse, status_code=201)
def create_application(
    application: JobApplicationCreate, db: Session = Depends(get_db)
):
    db_application = JobApplication(**application.model_dump())
    db.add(db_application)
    db.commit()
    db.refresh(db_application)
    return db_application


@router.get("/", response_model=list[JobApplicationResponse])
def list_applications(status: str | None = None, db: Session = Depends(get_db)):
    query = db.query(JobApplication)
    if status:
        query = query.filter(JobApplication.status == status)
    return query.order_by(JobApplication.date_applied.desc()).all()


@router.get("/{application_id}", response_model=JobApplicationResponse)
def get_application(application_id: int, db: Session = Depends(get_db)):
    application = db.query(JobApplication).filter(
        JobApplication.id == application_id
    ).first()
    if not application:
        raise HTTPException(status_code=404, detail="Application not found")
    return application


@router.put("/{application_id}", response_model=JobApplicationResponse)
def update_application(
    application_id: int,
    updates: JobApplicationUpdate,
    db: Session = Depends(get_db),
):
    application = db.query(JobApplication).filter(
        JobApplication.id == application_id
    ).first()
    if not application:
        raise HTTPException(status_code=404, detail="Application not found")

    update_data = updates.model_dump(exclude_unset=True)
    for field, value in update_data.items():
        setattr(application, field, value)

    db.commit()
    db.refresh(application)
    return application


@router.delete("/{application_id}", status_code=204)
def delete_application(application_id: int, db: Session = Depends(get_db)):
    application = db.query(JobApplication).filter(
        JobApplication.id == application_id
    ).first()
    if not application:
        raise HTTPException(status_code=404, detail="Application not found")

    db.delete(application)
    db.commit()
Wire the router into the main app and test

The router is ready, but FastAPI does not know about it yet. You need to register the router in your main application file and create the database table.

  • Open app/main.py and paste the following code:
from fastapi import FastAPI

from app.database import engine, Base
from app.routers import applications

# Create all database tables defined by our models
Base.metadata.create_all(bind=engine)

app = FastAPI(title="Job Application Tracker", version="1.0.0")

# Register the applications router with the main app
app.include_router(applications.router)


@app.get("/")
def root():
    return {"message": "Job Application Tracker API"}

What does this code do?

Base.metadata.create_all(bind=engine) inspects all your SQLAlchemy models and creates their corresponding tables in PostgreSQL if they do not exist yet. This is how your job_applications table gets created.

app.include_router(applications.router) registers every endpoint from your router with the main FastAPI application. The root / endpoint is a simple health check that confirms the API is running.

  • Save app/main.py.
  • Start the development server by running this command in your terminal:
fastapi dev app/main.py
  • You should see output indicating the server is running at http://127.0.0.1:8000.
  • Open your browser and navigate to http://localhost:8000/docs.

You should see FastAPI's interactive Swagger UI page with your API title "Job Application Tracker" and all five endpoints listed under the "applications" tag.

What is Swagger UI?

Swagger UI is an interactive API documentation page that FastAPI generates automatically from your code. It lets you explore your endpoints, fill in parameters, and send real requests directly from the browser.

You do not need a separate tool like Postman to test your API. Swagger UI does it all in one page.

  • Click the POST /applications/ endpoint to expand it.
  • Click the Try it out button.
  • Replace the example JSON in the request body with this test data:
{
  "company": "NextWork",
  "position": "Backend Engineer",
  "status": "applied",
  "date_applied": "2026-06-01",
  "salary": 120000,
  "notes": "Applied through their website"
}
  • Click Execute.
  • You should see a 201 response with the full application object, including an auto-generated id, created_at, and updated_at timestamp.

Test the other endpoints too

Try the GET /applications/ endpoint to see your new application in the list. Use the status query parameter to filter by applied. Then try PUT to change the status to interviewing, and DELETE to remove it.

Server not starting?

Make sure your virtual environment is active (you should see (venv) in your terminal prompt). Also confirm Postgres.app is running with the elephant icon visible in your menu bar.

If you see a "database does not exist" error, run createdb job_tracker in a separate terminal window.

help me troubleshoot why my FastAPI app is not starting

✔️ Awesome, I've got everything!

Great. Make sure you have saved app/main.py and your server is running.

ⓧ I'd like to double check the full code

from fastapi import FastAPI

from app.database import engine, Base
from app.routers import applications

Base.metadata.create_all(bind=engine)

app = FastAPI(title="Job Application Tracker", version="1.0.0")

app.include_router(applications.router)


@app.get("/")
def root():
    return {"message": "Job Application Tracker API"}

Secret mission

Add a Statistics Endpoint

Your API can create, read, update, and delete applications. But what if you want a bird's-eye view of your job search? Build a stats endpoint that summarizes your applications by status and calculates your average salary target.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The job_tracker PostgreSQL database.
  • Your project folder (containing app/, requirements.txt, .env, and the venv virtual environment).
  • Postgres.app (installed in your Applications folder).
  • The PATH configuration file at /etc/paths.d/postgresapp.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the API with more features.

  • Your FastAPI dev server continues running in the terminal. Press Ctrl+C when you want to stop it temporarily.
  • Postgres.app keeps running in your menu bar. Your job_tracker database and any data you added through Swagger UI will persist between sessions.
  • To restart the API later, open your terminal, navigate to your project folder, activate the virtual environment with source venv/bin/activate, and run fastapi dev app/main.py.

Pause - I'll come back to this later

Shut down running processes to free up system resources while keeping all your files and data intact.

  • In the terminal where your FastAPI server is running, press Ctrl+C to stop the server.
  • Deactivate the virtual environment by running:
  • Quit Postgres.app by clicking its elephant icon in the menu bar and selecting Quit.
deactivate

Your project folder, database, and all configuration remain on disk. When you are ready to continue, open Postgres.app, activate the virtual environment, and start the server again.

Delete - I don't want to use this again

Remove all project resources completely and start fresh.

  • In the terminal where your FastAPI server is running, press Ctrl+C to stop the server.
  • Deactivate the virtual environment by running:
deactivate
  • Drop the job_tracker database by running:
dropdb job_tracker
  • Delete your project folder by navigating to your Desktop and removing it:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]
  • To fully uninstall Postgres.app, quit it from the menu bar icon, then drag it from your Applications folder to the Trash.
  • Delete the Postgres.app data directory by running:
rm -rf ~/Library/Application\ Support/Postgres
  • Remove the Postgres.app preferences by running:
defaults delete com.postgresapp.Postgres2
  • Remove the PATH configuration file by running:
sudo rm /etc/paths.d/postgresapp

After these steps, PostgreSQL and all project files are completely removed from your machine.

Nice Work!

Nice Work!

You just built a real, database-backed REST API from scratch. Your Job Application Tracker is a fully functional service with endpoints for creating, reading, updating, and deleting data in a PostgreSQL database.

You've learned how to:

  • Build a FastAPI application with a modular project structure separating models, schemas, routers, and configuration.
  • Connect to a PostgreSQL database using SQLAlchemy ORM and manage sessions with FastAPI's dependency injection.
  • Implement full CRUD operations with Pydantic schemas for automatic request validation and response serialization.
  • Added a statistics endpoint that aggregates data across your applications.

Ready to quiz yourself?