minimal-fastapi-postgres-template wires FastAPI to Postgres 18 with async SQLAlchemy and a 100 percent coverage gate
minimal-fastapi-postgres-template based on official template but rewritten
At a glance
- What is it?
- minimal-fastapi-postgres-template is a rewritten FastAPI starting point with async SQLAlchemy 2.0 over asyncpg, Alembic migrations, PyJWT and bcrypt auth, Prometheus metrics and a pytest configuration that fails the build below 100 percent coverage. It pins Python 3.14 exactly and ships a Makefile plus Docker Compose for the usual run.
- Who is it for?
- Adopt minimal-fastapi-postgres-template if you want a FastAPI service with authentication, migrations and a working test harness in place on day one, since the auth, the Alembic setup and the coverage gate are the parts every project ends up writing badly. Do not adopt it if your team cannot live with a Python pin of greater than or equal to 3.14 and less than 3.15, or if you want a specific framework's conventions rather than an opinionated wiring.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 84 days ago.
- What is it written in?
- Mainly Python, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 24, 2026, and from our analysis. They are not legal advice.
Editorial analysis
An async stack held together by SQLAlchemy 2.0 and asyncpg
The dependency list in pyproject.toml is short enough to read as a design statement. The runtime set is alembic, asyncpg, bcrypt, fastapi, prometheus-client, pydantic-settings, pydantic with the email extra, pyjwt, python-multipart and sqlalchemy with the asyncio extra.
What that set implies is a fully asynchronous request path. FastAPI's async handlers talk to Postgres through SQLAlchemy 2.0's async engine, which drives asyncpg rather than a synchronous driver. There is no psycopg2 or psycopg3 in the list, and no run_in_executor pattern mentioned, so the intent is that nothing in the request path blocks the event loop.
The SQLAlchemy version matters because of how the models are written. The README's worked example uses Mapped and mapped_column, the typed declarative style introduced in SQLAlchemy 2.0, and the feature list calls out best possible autocompletion support as the reason. That is a real benefit in an editor, since a column's type and nullability are known statically rather than inferred at runtime.
The example model is short:
# app/pets/models.py
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from app.core.models import Base
class Pet(Base):
__tablename__ = "pets_pet"
id: Mapped[int] = mapped_column(sa.BigInteger, primary_key=True)
user_id: Mapped[str] = mapped_column(
sa.ForeignKey("auth_user.user_id", ondelete="CASCADE"),
)
pet_name: Mapped[str] = mapped_column(sa.String(50), nullable=False)Two details in that class are worth pausing on. The primary key is a BigInteger rather than the more common Integer, and the foreign key to the user table is declared with ondelete="CASCADE", so deleting a user removes their pets in the database rather than leaving orphans or failing on a constraint violation.
The base class comes from app.core.models, which means the project has a central model registry. That detail is the reason for the README's step three in the walkthrough, which tells you to import your new models.py file in alembic env.py. Without that import, the migration tool does not know the table exists.
The database itself is PostgreSQL 18 under the asyncpg interface, which the feature list states as the target version rather than a minimum.
Configuration through nested-prefix environment variables
The .env.example file shows the configuration convention, and it is worth reading in full because every setting name tells you something about the layout.
SECURITY__BACKEND_CORS_ORIGINS=["http://localhost:3000","http://localhost:8001"]
SECURITY__ALLOWED_HOSTS=["localhost", "127.0.0.1"]
DATABASE__HOSTNAME=localhost
DATABASE__USERNAME=rDGJeEDqAz
DATABASE__PASSWORD=XsPQhCoEfOQZueDjsILetLDUvbvSxAMnrVtgVZpmdcSssUgbvs
DATABASE__PORT=5455
DATABASE__DB=default_db
PROMETHEUS__ENABLED=true
PROMETHEUS__ADDR="127.0.0.1"The nested separator is the nested-settings delimiter that pydantic-settings uses, so SECURITY__JWT_SECRET_KEY becomes security.jwt_secret_key in the settings object, and DATABASE__PASSWORD becomes database.password. Three groups, three prefixes, and a naming rule you can extend without inventing a new mechanism.
The values themselves carry decisions. The CORS origins are a JSON array rather than a comma-separated string, which is what pydantic-settings parses for list-typed fields. The allowed hosts list localhost and 127.0.0.1. The database port is 5455 on the host, deliberately not 5432, so the Compose service can keep the standard port internally. And the Prometheus address is bound to 127.0.0.1, meaning the metrics endpoint is local by default and someone has to change that deliberately.
The JWT secret in the example file is a literal placeholder string rather than an obvious marker, which is a small trap for anyone who copies .env.example to .env without reading it. A template like this is copied constantly, and a shipped secret key that looks like a real random value is exactly the kind of thing that survives into production.
The docker-compose.yml wires the same variable names into the Postgres service, which is what makes the Compose file and the application agree on configuration:
postgres_db:
restart: unless-stopped
image: postgres:18
volumes:
- postgres_db:/var/lib/postgresql
environment:
- POSTGRES_DB=${DATABASE__DB}
- POSTGRES_USER=${DATABASE__USERNAME}
- POSTGRES_PASSWORD=${DATABASE__PASSWORD}
env_file:
- .env
ports:
- "${DATABASE__PORT}:5432"The named volume is what makes the database survive a container replacement, and the restart policy brings it back after a host reboot.
Running it: uv sync, make up, and the manual path when the Makefile is not enough
The quickstart is five steps, and the first is either a template repository or a plain clone. The dependency step uses uv rather than pip:
cd your_project_name
uv syncThe README says uv should automatically install the Python version currently required by the template, greater than or equal to 3.14, or use an existing installation if you already have it. That is the pyproject constraint requires-python = ">=3.14,<3.15" doing the work, and the upper bound is worth noticing: this template pins one minor version of Python and will refuse to install on 3.15.
The run step is a single make target:
make upThe README points at the Makefile for the shortcuts and notes that apt install build-essential is needed on Linux. When you would rather not use the Makefile, the equivalent sequence is spelled out:
docker compose up -d
alembic upgrade head
uvicorn app.main:app --reloadThose three lines are the whole local environment: the database, the schema, and the server. After that you initialise git if needed and open http://localhost:8000/ for the OpenAPI spec.
The Docker image is worth reading because it makes two choices. The base is python:3.14-slim-trixie, and the build stage copies the uv binary from a pinned image, ghcr.io/astral-sh/uv:0.9.2, then exports a frozen requirements file from uv.lock with --no-hashes --frozen. Frozen means the lockfile must match pyproject.toml or the build fails, which is the right behaviour for a reproducible image. The runtime stage builds a virtualenv at /venv, puts it on PATH, and installs the exported requirements with pip rather than uv, which keeps the final image free of the uv binary.
The entrypoint is init.sh rather than uvicorn directly, and the CMD runs uvicorn with the uvloop event loop and a single worker:
EXPOSE 8000
EXPOSE 9090
ENTRYPOINT ["./init.sh"]
CMD ["/venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--loop", "uvloop"]Two ports are exposed, 8000 for the app and 9090 for the Prometheus metrics, and the single worker plus uvloop is a specific choice: the async stack gains from uvloop's event loop, and one worker avoids a class of in-process state problems that come with multiple workers in a template.
The test configuration that fails below 100 percent coverage
The pytest configuration in pyproject.toml is the strictest thing in the template, and it is where the coverage claim comes from.
addopts = "-vv -n auto --cov --cov-report xml --cov-report term-missing --cov-fail-under=100"
asyncio_default_test_loop_scope = "session"
asyncio_mode = "auto"
filterwarnings = [""]
testpaths = ["app"]The -n auto flag runs tests in as many processes as the machine has CPUs, using pytest-xdist, and the README explains why: the test suite creates databases, so parallelising the database work is what makes the run bearable. The asyncio settings put pytest-asyncio in auto mode with a session-scoped event loop, so async test functions need no marker.
The coverage configuration completes the picture:
[tool.coverage.run]
concurrency = ["greenlet"]
omit = ["alembic/*", "app/tests/*", "conftest.py", "test_*.py"]
source = ["app"]The greenlet concurrency setting is there because SQLAlchemy's async engine runs its greenlet-based work inside the same greenlet tree, and coverage.py needs to be told to track it. Excluding alembic from coverage is a deliberate choice: migration scripts are executed as code, but testing them for line coverage buys nothing.
The dev dependency group shows what makes this work. httpx is the async client for testing FastAPI endpoints, pytest-asyncio and pytest-xdist handle the async and parallel execution, polyfactory provides test data factories, and freezegun freezes time for token expiry tests. The README's walkthrough even has a step titled adding the Pet model to the test factories, which tells you the factories are the intended way to build test data rather than fixtures assembled by hand.
Running the suite is either pytest or make test, and the README notes that the initial database setup lives in app/conftest.py, in a function called fixture_setup_new_test_database.
Lint and type checking configured the way the author prefers
The pre-commit setup is described as the de facto standard for pre-push activities like isort or black or its replacement ruff, and the README points at .pre-commit-config.yaml for the author's opinionated choices. The short version is a make target:
# Shortcut
make lintAnd the full commands are the standard pre-commit pair:
# Install pre-commit
pre-commit install --install-hooks
# Run on all files
pre-commit run --all-filesThe ruff configuration in pyproject.toml selects a broad rule set:
[tool.ruff.lint]
# pycodestyle, pyflakes, isort, pylint, pyupgrade
ignore = ["E501"]
select = ["E", "F", "I", "PL", "UP", "W"]The comment names the intent: pycodestyle, pyflakes, isort, pylint and pyupgrade all in one, which is what ruff is for. E501 is ignored, so the long line length rule is off, and target-version is py314, so the upgrade rules know which Python you are on.
The mypy configuration is the other half and it is strict:
[tool.mypy]
exclude = [".venv", "alembic"]
python_version = "3.14"
strict = trueStrict mode means no untyped defs and no implicit optionals, which for a template is a reasonable bar because it forces every new endpoint to declare its types rather than inheriting looseness. The exclusion of alembic matches the coverage exclusion, so migrations are not held to the same standard as application code.
The result is that a new project inherits three quality gates rather than zero: ruff on commit, mypy in the same hook chain, and coverage below 100 percent failing the test run. That is the template's actual product, and it is the part that will cost you the most when you start deleting code to get a project off the ground, since the coverage gate does not relax when you remove a feature.
Where minimal-fastapi-postgres-template is the wrong starting point
Three limits, and the first is a version constraint that will end a deploy.
The first is the Python pin. requires-python is >=3.14,<3.15, so the template installs on exactly one minor version. When 3.15 arrives, every project generated from this template needs that bound widened, and the Dockerfile base image python:3.14-slim-trixie has to move with it. A template that pins one minor version makes a deliberate, small choice, but it also means the upgrade is yours rather than the template author's.
The second is the opinionated defaults that are hard to undo. The JWT secret in .env.example is a literal value, CORS origins and allowed hosts are pre-filled for local development, and the coverage gate is at 100 percent with migrations excluded but nothing else. The exclusion list is the issue: coverage.fail_under is 100 for the app package, so the first endpoint you add without a test fails the suite, and the first module you deliberately delete leaves you rewriting tests rather than deleting them.
The third is what the README does not document. There is no section on deployment beyond the Dockerfile and the Compose file, no migration strategy for a live database beyond running alembic upgrade head, no discussion of what the init.sh entrypoint does, and no rollback path. For a template that bills itself as minimal, that silence is defensible, but it does mean the gap between the template and a running service is exactly where your own work goes.
The alternative approach is the official FastAPI template, which ships with SQLAlchemy and Pydantic models, a project structure and a dependency set, and leaves the database layer as a choice. The difference in approach is that the official template gives you a starting point you can shape, while this one gives you a decided system: asyncpg over Postgres 18, Alembic with its own conftest, PyJWT with bcrypt, Prometheus on a second port, and four quality gates already wired. If you would rather write those decisions yourself and keep them few, the official template is less work to unpick. If you want them already made and consistent, and you are willing to live with the Python pin and the coverage gate, this one saves the first week of a project.
Editorial conclusion
Adopt minimal-fastapi-postgres-template if you want a FastAPI service with authentication, migrations and a working test harness in place on day one, since the auth, the Alembic setup and the coverage gate are the parts every project ends up writing badly. Do not adopt it if your team cannot live with a Python pin of greater than or equal to 3.14 and less than 3.15, or if you want a specific framework's conventions rather than an opinionated wiring. Verify first that the Docker Compose file maps DATABASE__PORT to the postgres:18 service and that your .env matches the variable names in .env.example, that the Alembic env.py imports every model module you add, since the README makes that an explicit manual step, that a fresh template's own test suite passes its 100 percent coverage gate on your machine before you change anything, and that the prometheus-client endpoint on port 9090 is reachable only where you intend it to be.
Frequently asked questions
How do I set up minimal-fastapi-postgres-template locally?
Clone the repository or create one from the template, run uv sync inside the project directory to install the pinned Python and dependencies, then run make up. The README also gives the manual sequence of docker compose up -d, alembic upgrade head and uvicorn app.main:app --reload, with the OpenAPI spec at http://localhost:8000/.
What database does the template use and how are queries run?
It targets PostgreSQL 18 through the asyncpg interface, with SQLAlchemy 2.0 in async mode. The models use the typed Mapped and mapped_column style, and the Docker Compose file runs a postgres:18 service with a named volume so data survives a container replacement.
How do I add a new model and its migration?
Create the model in its own app subpackage using Mapped and mapped_column, then import the new models.py file in alembic env.py, which the README lists as a separate manual step. After that you create and apply the Alembic migration, add request and response schemas, and write the endpoints.
What does the test setup require?
Pytest is configured with -n auto for parallel execution, asyncio_mode auto with a session-scoped loop, and --cov-fail-under=100, so the suite fails below full coverage. The initial database setup lives in app/conftest.py in fixture_setup_new_test_database, and pytest-xdist runs the work across processes because each test needs a database.
Which Python version does the template need?
The pyproject.toml sets requires-python to >=3.14,<3.15, so uv sync installs Python 3.14 if you do not already have it, and the Dockerfile is based on python:3.14-slim-trixie. mypy is configured with python_version 3.14 and ruff with target-version py314.
Where are the Prometheus metrics exposed?
The Dockerfile exposes port 8000 for the app and port 9090 for Prometheus metrics, and the .env.example file sets PROMETHEUS__ENABLED=true with PROMETHEUS__ADDR bound to 127.0.0.1, so the metrics listener is local by default. The prometheus-client package is a runtime dependency.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/rafsaf-minimal-fastapi-postgres-template)Was this useful?