Fix grammar and improve text readability (#81)

* Fix grammar

* Add FastAPI best practices skill

* Add CLAUDE.md with FastAPI best practices

* Add more tips to CLAUDE.md

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Yerassyl
2026-01-04 03:28:33 +05:00
committed by GitHub
parent 2609be3f6f
commit da5b691ad1
2 changed files with 88 additions and 61 deletions

39
CLAUDE.md Normal file
View File

@@ -0,0 +1,39 @@
# FastAPI Best Practices
## Project Structure
Organize by domain (`src/auth/`, `src/posts/`), not file type. Each domain has: `router.py`, `schemas.py`, `models.py`, `service.py`, `dependencies.py`, `config.py`, `exceptions.py`.
## Async Routes
- `async def` → non-blocking I/O only
- `def` (sync) → blocking operations (runs in threadpool)
- CPU-intensive → offload to worker processes (Celery, multiprocessing)
- Sync SDK? Use `run_in_threadpool` from Starlette
## Pydantic
- Use extensively: regex, enums, Field constraints, EmailStr
- Split BaseSettings per domain
- Create custom base model for app-wide serialization
- ValueError in schema → returns ValidationError to client
## Dependencies
- Use for DB/service validations, not just DI
- Chain dependencies to avoid repetition
- Prefer `async` dependencies
- Dependencies are cached per request
## Follow the REST
- Consistent path variable names enable dependency reuse
- `/profiles/{profile_id}` and `/creators/{profile_id}` can share `valid_profile_id` dependency
## Database
- Explicit naming conventions for indexes/constraints
- `lower_case_snake`, singular table names
- SQL-first for joins and aggregations
- Aggregate nested JSON in DB, not Python
## Migrations (Alembic)
- Keep migrations static and reversible
- Use descriptive slugs: `2022-08-24_post_content_idx.py`
## Testing
- Async test client from day 0 (httpx)

108
README.md
View File

@@ -1,9 +1,9 @@
## FastAPI Best Practices <!-- omit from toc --> ## FastAPI Best Practices <!-- omit from toc -->
Opinionated list of best practices and conventions I use in startups. Opinionated list of best practices and conventions we use at our startups.
For the last several years in production, After several years of building production systems,
we have been making good and bad decisions that impacted our developer experience dramatically. we've made both good and bad decisions that significantly impacted our developer experience.
Some of them are worth sharing. Here are some lessons worth sharing.
*[简体中文](./README_ZH.md)* *[简体中文](./README_ZH.md)*
@@ -38,9 +38,9 @@ Some of them are worth sharing.
## Project Structure ## Project Structure
There are many ways to structure a project, but the best structure is one that is consistent, straightforward, and free of surprises. There are many ways to structure a project, but the best structure is one that is consistent, straightforward, and free of surprises.
Many example projects and tutorials divide the project by file type (e.g., crud, routers, models), which works well for microservices or projects with fewer scopes. However, this approach didn't fit our monolith with many domains and modules. Many example projects and tutorials organize projects by file type (e.g., crud, routers, models), which works well for microservices or smaller projects. However, this approach didn't scale well for our monolith with many domains and modules.
The structure I found more scalable and evolvable for these cases is inspired by Netflix's [Dispatch](https://github.com/Netflix/dispatch), with some minor modifications. The structure I found more scalable and evolvable is inspired by Netflix's [Dispatch](https://github.com/Netflix/dispatch), with some minor modifications.
``` ```
fastapi-project fastapi-project
├── alembic/ ├── alembic/
@@ -113,20 +113,16 @@ from src.posts.constants import ErrorCode as PostsErrorCode # in case we have S
``` ```
## Async Routes ## Async Routes
FastAPI is an async framework, in the first place. It is designed to work with async I/O operations and that is the reason it is so fast. FastAPI is an async-first framework—it's designed to work with async I/O operations, which is why it's so fast.
However, FastAPI doesn't restrict you to use only `async` routes, and the developer can use `sync` routes as well. This might confuse beginner developers into believing that they are the same, but they are not. However, FastAPI doesn't restrict you to only `async` routes; you can use `sync` routes too. This might confuse beginners into thinking they're the same, but they're not.
### I/O Intensive Tasks ### I/O Intensive Tasks
Under the hood, FastAPI can [effectively handle](https://fastapi.tiangolo.com/async/#path-operation-functions) both async and sync I/O operations. Under the hood, FastAPI can [effectively handle](https://fastapi.tiangolo.com/async/#path-operation-functions) both async and sync I/O operations:
- FastAPI runs `sync` routes in the [threadpool](https://en.wikipedia.org/wiki/Thread_pool) - FastAPI runs `sync` routes in a [threadpool](https://en.wikipedia.org/wiki/Thread_pool), so blocking I/O operations won't stop the [event loop](https://docs.python.org/3/library/asyncio-eventloop.html) from executing other tasks.
and blocking I/O operations won't stop the [event loop](https://docs.python.org/3/library/asyncio-eventloop.html) - If the route is defined as `async`, it's called via `await` and FastAPI trusts you to only perform non-blocking I/O operations.
from executing the tasks.
- If the route is defined `async` then it's called regularly via `await`
and FastAPI trusts you to do only non-blocking I/O operations.
The caveat is that if you violate that trust and execute blocking operations within async routes, The caveat is that if you violate that trust and execute blocking operations within async routes, the event loop won't be able to run other tasks until the blocking operation completes.
the event loop will not be able to run subsequent tasks until the blocking operation completes.
```python ```python
import asyncio import asyncio
import time import time
@@ -159,24 +155,23 @@ async def perfect_ping():
**What happens when we call:** **What happens when we call:**
1. `GET /terrible-ping` 1. `GET /terrible-ping`
1. FastAPI server receives a request and starts handling it 1. FastAPI server receives a request and starts handling it
2. Server's event loop and all the tasks in the queue will be waiting until `time.sleep()` is finished 2. Server's event loop and all queued tasks wait until `time.sleep()` finishes
1. Server thinks `time.sleep()` is not an I/O task, so it waits until it is finished 1. Since the route is `async`, the server doesn't offload it to a threadpool—it blocks the entire event loop
2. Server won't accept any new requests while waiting 2. Server won't accept any new requests while waiting
3. Server returns the response. 3. Server returns the response
1. After a response, server starts accepting new requests 1. Only after responding does the server resume accepting new requests
2. `GET /good-ping` 2. `GET /good-ping`
1. FastAPI server receives a request and starts handling it 1. FastAPI server receives a request and starts handling it
2. FastAPI sends the whole route `good_ping` to the threadpool, where a worker thread will run the function 2. FastAPI sends the entire `good_ping` route to the threadpool, where a worker thread runs the function
3. While `good_ping` is being executed, event loop selects next tasks from the queue and works on them (e.g. accept new request, call db) 3. While `good_ping` executes, the event loop continues processing other tasks (e.g., accepting new requests, calling the database)
- Independently of main thread (i.e. our FastAPI app), - The worker thread waits for `time.sleep` to finish, independently of the main thread
worker thread will be waiting for `time.sleep` to finish. - The sync operation blocks only the worker thread, not the main event loop
- Sync operation blocks only the side thread, not the main one. 4. When `good_ping` finishes, the server returns a response to the client
4. When `good_ping` finishes its work, server returns a response to the client
3. `GET /perfect-ping` 3. `GET /perfect-ping`
1. FastAPI server receives a request and starts handling it 1. FastAPI server receives a request and starts handling it
2. FastAPI awaits `asyncio.sleep(10)` 2. FastAPI awaits `asyncio.sleep(10)`
3. Event loop selects next tasks from the queue and works on them (e.g. accept new request, call db) 3. Event loop continues processing other tasks from the queue (e.g., accepting new requests, calling the database)
4. When `asyncio.sleep(10)` is done, servers finishes the execution of the route and returns a response to the client 4. When `asyncio.sleep(10)` completes, the server finishes executing the route and returns a response to the client
> [!WARNING] > [!WARNING]
> Notes on the thread pool: > Notes on the thread pool:
@@ -184,12 +179,10 @@ async def perfect_ping():
> - Thread pool has a limited number of threads, i.e. you might run out of threads and your app will become slow. [Read more](https://github.com/Kludex/fastapi-tips?tab=readme-ov-file#2-be-careful-with-non-async-functions) (external link) > - Thread pool has a limited number of threads, i.e. you might run out of threads and your app will become slow. [Read more](https://github.com/Kludex/fastapi-tips?tab=readme-ov-file#2-be-careful-with-non-async-functions) (external link)
### CPU Intensive Tasks ### CPU Intensive Tasks
The second caveat is that operations that are non-blocking awaitables or are sent to the thread pool must be I/O intensive tasks (e.g. open file, db call, external API call). The second caveat is that non-blocking awaitables and threadpool offloading are only beneficial for I/O intensive tasks (e.g., file operations, database calls, external API requests).
- Awaiting CPU-intensive tasks (e.g. heavy calculations, data processing, video transcoding) is worthless since the CPU has to work to finish the tasks, - Awaiting CPU-intensive tasks (e.g., heavy calculations, data processing, video transcoding) provides no benefit since the CPU must actively work to complete them. In contrast, I/O operations are external—the server just waits for a response and can handle other tasks in the meantime.
while I/O operations are external and server does nothing while waiting for that operations to finish, thus it can go to the next tasks. - Running CPU-intensive tasks in other threads is also ineffective due to the [GIL](https://realpython.com/python-gil/). In short, the GIL allows only one thread to execute Python bytecode at a time, making threads ineffective for CPU-bound work.
- Running CPU-intensive tasks in other threads also isn't effective, because of [GIL](https://realpython.com/python-gil/). - To optimize CPU-intensive tasks, you should offload them to worker processes (e.g., using `multiprocessing` or a task queue like Celery).
In short, GIL allows only one thread to work at a time, which makes it useless for CPU tasks.
- If you want to optimize CPU intensive tasks you should send them to workers in another process.
**Related StackOverflow questions of confused users** **Related StackOverflow questions of confused users**
1. https://stackoverflow.com/questions/62976648/architecture-flask-vs-fastapi/70309597#70309597 1. https://stackoverflow.com/questions/62976648/architecture-flask-vs-fastapi/70309597#70309597
@@ -201,8 +194,8 @@ In short, GIL allows only one thread to work at a time, which makes it useless f
### Excessively use Pydantic ### Excessively use Pydantic
Pydantic has a rich set of features to validate and transform data. Pydantic has a rich set of features to validate and transform data.
In addition to regular features like required & non-required fields with default values, In addition to standard features like required and optional fields with default values,
Pydantic has built-in comprehensive data processing tools like regex, enums, strings manipulation, emails validation, etc. Pydantic has built-in data processing tools like regex validation, enums, string manipulation, email validation, and more.
```python ```python
from enum import Enum from enum import Enum
from pydantic import AnyUrl, BaseModel, EmailStr, Field from pydantic import AnyUrl, BaseModel, EmailStr, Field
@@ -257,7 +250,7 @@ In the example above, we have decided to create a global base model that:
- Serializes all datetime fields to a standard format with an explicit timezone - Serializes all datetime fields to a standard format with an explicit timezone
- Provides a method to return a dict with only serializable fields - Provides a method to return a dict with only serializable fields
### Decouple Pydantic BaseSettings ### Decouple Pydantic BaseSettings
BaseSettings was a great innovation for reading environment variables, but having a single BaseSettings for the whole app can become messy over time. To improve maintainability and organization, we have split the BaseSettings across different modules and domains. BaseSettings is great for reading environment variables, but a single BaseSettings for the whole app gets messy. Split it across modules and domains.
```python ```python
# src.auth.config # src.auth.config
from datetime import timedelta from datetime import timedelta
@@ -309,11 +302,11 @@ settings = Config()
## Dependencies ## Dependencies
### Beyond Dependency Injection ### Beyond Dependency Injection
Pydantic is a great schema validator, but for complex validations that involve calling a database or external services, it is not sufficient. Pydantic is a great schema validator, but for complex validations that require database or external service calls, it's not enough.
FastAPI documentation mostly presents dependencies as DI for endpoints, but they are also excellent for request validation. FastAPI docs mostly present dependencies as DI for endpoints, but they're also great for request validation.
Dependencies can be used to validate data against database constraints (e.g., checking if an email already exists, ensuring a user is found, etc.). Dependencies can validate data against database constraints (e.g., checking if an email already exists, ensuring a user exists, etc.).
```python ```python
# dependencies.py # dependencies.py
async def valid_post_id(post_id: UUID4) -> dict[str, Any]: async def valid_post_id(post_id: UUID4) -> dict[str, Any]:
@@ -344,8 +337,8 @@ async def get_post_reviews(post: dict[str, Any] = Depends(valid_post_id)):
post_reviews = await reviews_service.get_by_post_id(post["id"]) post_reviews = await reviews_service.get_by_post_id(post["id"])
return post_reviews return post_reviews
``` ```
If we didn't put data validation to dependency, we would have to validate `post_id` exists If we didn't put data validation in a dependency, we would have to validate that `post_id` exists
for every endpoint and write the same tests for each of them. in every endpoint and write the same tests for each of them.
### Chain Dependencies ### Chain Dependencies
Dependencies can use other dependencies and avoid code repetition for similar logic. Dependencies can use other dependencies and avoid code repetition for similar logic.
@@ -462,9 +455,9 @@ async def get_user_post(
``` ```
### Prefer `async` dependencies ### Prefer `async` dependencies
FastAPI supports both `sync` and `async` dependencies, and there is a temptation to use `sync` dependencies, when you don't have to await anything, but that might not be the best choice. FastAPI supports both `sync` and `async` dependencies. It's tempting to use `sync` when you don't need to await anything, but that's not the best choice.
Just as with routes, `sync` dependencies are run in the thread pool. And threads here also come with a price and limitations, that are redundant, if you just make a small non-I/O operation. Just like routes, `sync` dependencies run in a threadpool. Threads have overhead that's unnecessary for small non-I/O operations.
[See more](https://github.com/Kludex/fastapi-tips?tab=readme-ov-file#9-your-dependencies-may-be-running-on-threads) (external link) [See more](https://github.com/Kludex/fastapi-tips?tab=readme-ov-file#9-your-dependencies-may-be-running-on-threads) (external link)
@@ -512,11 +505,9 @@ async def get_user_profile_by_id(
``` ```
### FastAPI response serialization ### FastAPI response serialization
You may think you can return Pydantic object that matches your route's `response_model` to make some optimizations, You might think you can return a Pydantic object that matches your route's `response_model` and skip some processing steps, but you'd be wrong.
but you'd be wrong.
FastAPI first converts that pydantic object to dict with its `jsonable_encoder`, then validates FastAPI first converts the Pydantic object to a dict using `jsonable_encoder`, then validates the data against your `response_model`, and only then serializes it to JSON.
data with your `response_model`, and only then serializes your object to JSON.
This means your Pydantic model object is created twice: This means your Pydantic model object is created twice:
- First, when you explicitly create it to return from your route. - First, when you explicitly create it to return from your route.
@@ -524,7 +515,7 @@ This means your Pydantic model object is created twice:
```python ```python
from fastapi import FastAPI from fastapi import FastAPI
from pydantic import BaseModel, root_validator from pydantic import BaseModel, model_validator
app = FastAPI() app = FastAPI()
@@ -548,10 +539,9 @@ async def root():
``` ```
### If you must use sync SDK, then run it in a thread pool. ### If you must use sync SDK, then run it in a thread pool.
If you must use a library to interact with external services, and it's not `async`, If you must use a library that's not `async`, run the HTTP calls in an external worker thread.
then make the HTTP calls in an external worker thread.
We can use the well-known `run_in_threadpool` from starlette. Use `run_in_threadpool` from Starlette.
```python ```python
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
@@ -569,7 +559,7 @@ async def call_my_sync_library():
``` ```
### ValueErrors might become Pydantic ValidationError ### ValueErrors might become Pydantic ValidationError
If you raise a `ValueError` in a Pydantic schema that is directly faced by the client, it will return a nice detailed response to users. If you raise a `ValueError` in a Pydantic schema that's used directly in a request body, FastAPI will return a detailed validation error response to users.
```python ```python
# src.profiles.schemas # src.profiles.schemas
from pydantic import BaseModel, field_validator from pydantic import BaseModel, field_validator
@@ -674,11 +664,9 @@ POSTGRES_INDEXES_NAMING_CONVENTION = {
metadata = MetaData(naming_convention=POSTGRES_INDEXES_NAMING_CONVENTION) metadata = MetaData(naming_convention=POSTGRES_INDEXES_NAMING_CONVENTION)
``` ```
### Migrations. Alembic ### Migrations. Alembic
1. Migrations must be static and revertable. 1. Migrations must be static and reversible. If your migrations depend on dynamically generated data, make sure only the data itself is dynamic, not its structure.
If your migrations depend on dynamically generated data, then 2. Generate migrations with descriptive names and slugs. The slug is required and should explain the changes.
make sure the only thing that is dynamic is the data itself, not its structure. 3. Set a human-readable file template for new migrations. We use the `*date*_*slug*.py` pattern, e.g., `2022-08-24_post_content_idx.py`
2. Generate migrations with descriptive names & slugs. Slug is required and should explain the changes.
3. Set human-readable file template for new migrations. We use `*date*_*slug*.py` pattern, e.g. `2022-08-24_post_content_idx.py`
``` ```
# alembic.ini # alembic.ini
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s
@@ -779,7 +767,7 @@ async def get_creator_posts(creator: dict[str, Any] = Depends(valid_creator_id))
return posts return posts
``` ```
### Set tests client async from day 0 ### Set tests client async from day 0
Writing integration tests with DB will most likely lead to messed up event loop errors in the future. Writing integration tests with DB will likely lead to messed up event loop errors in the future.
Set the async test client immediately, e.g. [httpx](https://github.com/encode/starlette/issues/652) Set the async test client immediately, e.g. [httpx](https://github.com/encode/starlette/issues/652)
```python ```python
import pytest import pytest
@@ -802,7 +790,7 @@ async def test_create_post(client: TestClient):
assert resp.status_code == 201 assert resp.status_code == 201
``` ```
Unless you have sync db connections (excuse me?) or aren't planning to write integration tests. Unless you have synchronous database connections (excuse me?) or don't plan to write integration tests.
### Use ruff ### Use ruff
With linters, you can forget about formatting the code and focus on writing the business logic. With linters, you can forget about formatting the code and focus on writing the business logic.