├── images ├── type_hints.png ├── type_hintsless.png ├── custom_responses.png └── value_error_response.png └── README.md /images/type_hints.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Todo/fastapi-best-practices/master/images/type_hints.png -------------------------------------------------------------------------------- /images/type_hintsless.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Todo/fastapi-best-practices/master/images/type_hintsless.png -------------------------------------------------------------------------------- /images/custom_responses.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Todo/fastapi-best-practices/master/images/custom_responses.png -------------------------------------------------------------------------------- /images/value_error_response.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Todo/fastapi-best-practices/master/images/value_error_response.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## FastAPI Best Practices 2 | Opinionated list of best practices and conventions I use in startups. 3 | 4 | For the last several years in production, 5 | we have been making good and bad decisions that impacted our developer experience dramatically. 6 | Some of them are worth sharing. 7 | 8 | ## Contents 9 | - [Project Structure](#project-structure) 10 | - [Async Routes](#async-routes) 11 | - [I/O Intensive Tasks](#io-intensive-tasks) 12 | - [CPU Intensive Tasks](#cpu-intensive-tasks) 13 | - [Pydantic](#pydantic) 14 | - [Excessively use Pydantic](#excessively-use-pydantic) 15 | - [Custom Base Model](#custom-base-model) 16 | - [Decouple Pydantic BaseSettings](#decouple-pydantic-basesettings) 17 | - [Dependencies](#dependencies) 18 | - [Beyond Dependency Injection](#beyond-dependency-injection) 19 | - [Chain Dependencies](#chain-dependencies) 20 | - [Decouple \& Reuse dependencies. Dependency calls are cached](#decouple--reuse-dependencies-dependency-calls-are-cached) 21 | - [Prefer `async` dependencies](#prefer-async-dependencies) 22 | - [Miscellaneous](#miscellaneous) 23 | - [Follow the REST](#follow-the-rest) 24 | - [FastAPI response serialization](#fastapi-response-serialization) 25 | - [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) 26 | - [ValueErrors might become Pydantic ValidationError](#valueerrors-might-become-pydantic-validationerror) 27 | - [Docs](#docs) 28 | - [Set DB keys naming conventions](#set-db-keys-naming-conventions) 29 | - [Migrations. Alembic](#migrations-alembic) 30 | - [Set DB naming conventions](#set-db-naming-conventions) 31 | - [SQL-first. Pydantic-second](#sql-first-pydantic-second) 32 | - [Set tests client async from day 0](#set-tests-client-async-from-day-0) 33 | - [Use ruff](#use-ruff) 34 | - [Bonus Section](#bonus-section) 35 | 36 | ## Project Structure 37 | There are many ways to structure a project, but the best structure is one that is consistent, straightforward, and free of surprises. 38 | 39 | 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. 40 | 41 | 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. 42 | ``` 43 | fastapi-project 44 | ├── alembic/ 45 | ├── src 46 | │ ├── auth 47 | │ │ ├── router.py 48 | │ │ ├── schemas.py # pydantic models 49 | │ │ ├── models.py # db models 50 | │ │ ├── dependencies.py 51 | │ │ ├── config.py # local configs 52 | │ │ ├── constants.py 53 | │ │ ├── exceptions.py 54 | │ │ ├── service.py 55 | │ │ └── utils.py 56 | │ ├── aws 57 | │ │ ├── client.py # client model for external service communication 58 | │ │ ├── schemas.py 59 | │ │ ├── config.py 60 | │ │ ├── constants.py 61 | │ │ ├── exceptions.py 62 | │ │ └── utils.py 63 | │ └── posts 64 | │ │ ├── router.py 65 | │ │ ├── schemas.py 66 | │ │ ├── models.py 67 | │ │ ├── dependencies.py 68 | │ │ ├── constants.py 69 | │ │ ├── exceptions.py 70 | │ │ ├── service.py 71 | │ │ └── utils.py 72 | │ ├── config.py # global configs 73 | │ ├── models.py # global models 74 | │ ├── exceptions.py # global exceptions 75 | │ ├── pagination.py # global module e.g. pagination 76 | │ ├── database.py # db connection related stuff 77 | │ └── main.py 78 | ├── tests/ 79 | │ ├── auth 80 | │ ├── aws 81 | │ └── posts 82 | ├── templates/ 83 | │ └── index.html 84 | ├── requirements 85 | │ ├── base.txt 86 | │ ├── dev.txt 87 | │ └── prod.txt 88 | ├── .env 89 | ├── .gitignore 90 | ├── logging.ini 91 | └── alembic.ini 92 | ``` 93 | 1. Store all domain directories inside `src` folder 94 | 1. `src/` - highest level of an app, contains common models, configs, and constants, etc. 95 | 2. `src/main.py` - root of the project, which inits the FastAPI app 96 | 2. Each package has its own router, schemas, models, etc. 97 | 1. `router.py` - is a core of each module with all the endpoints 98 | 2. `schemas.py` - for pydantic models 99 | 3. `models.py` - for db models 100 | 4. `service.py` - module specific business logic 101 | 5. `dependencies.py` - router dependencies 102 | 6. `constants.py` - module specific constants and error codes 103 | 7. `config.py` - e.g. env vars 104 | 8. `utils.py` - non-business logic functions, e.g. response normalization, data enrichment, etc. 105 | 9. `exceptions.py` - module specific exceptions, e.g. `PostNotFound`, `InvalidUserData` 106 | 3. When package requires services or dependencies or constants from other packages - import them with an explicit module name 107 | ```python 108 | from src.auth import constants as auth_constants 109 | from src.notifications import service as notification_service 110 | from src.posts.constants import ErrorCode as PostsErrorCode # in case we have Standard ErrorCode in constants module of each package 111 | ``` 112 | 113 | ## Async Routes 114 | 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. 115 | 116 | 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. 117 | 118 | ### I/O Intensive Tasks 119 | Under the hood, FastAPI can [effectively handle](https://fastapi.tiangolo.com/async/#path-operation-functions) both async and sync I/O operations. 120 | - FastAPI runs `sync` routes in the [threadpool](https://en.wikipedia.org/wiki/Thread_pool) 121 | and blocking I/O operations won't stop the [event loop](https://docs.python.org/3/library/asyncio-eventloop.html) 122 | from executing the tasks. 123 | - If the route is defined `async` then it's called regularly via `await` 124 | and FastAPI trusts you to do only non-blocking I/O operations. 125 | 126 | The caveat is that if you violate that trust and execute blocking operations within async routes, 127 | the event loop will not be able to run subsequent tasks until the blocking operation completes. 128 | ```python 129 | import asyncio 130 | import time 131 | 132 | from fastapi import APIRouter 133 | 134 | 135 | router = APIRouter() 136 | 137 | 138 | @router.get("/terrible-ping") 139 | async def terrible_ping(): 140 | time.sleep(10) # I/O blocking operation for 10 seconds, the whole process will be blocked 141 | 142 | return {"pong": True} 143 | 144 | @router.get("/good-ping") 145 | def good_ping(): 146 | time.sleep(10) # I/O blocking operation for 10 seconds, but in a separate thread for the whole `good_ping` route 147 | 148 | return {"pong": True} 149 | 150 | @router.get("/perfect-ping") 151 | async def perfect_ping(): 152 | await asyncio.sleep(10) # non-blocking I/O operation 153 | 154 | return {"pong": True} 155 | 156 | ``` 157 | **What happens when we call:** 158 | 1. `GET /terrible-ping` 159 | 1. FastAPI server receives a request and starts handling it 160 | 2. Server's event loop and all the tasks in the queue will be waiting until `time.sleep()` is finished 161 | 1. Server thinks `time.sleep()` is not an I/O task, so it waits until it is finished 162 | 2. Server won't accept any new requests while waiting 163 | 3. Server returns the response. 164 | 1. After a response, server starts accepting new requests 165 | 2. `GET /good-ping` 166 | 1. FastAPI server receives a request and starts handling it 167 | 2. FastAPI sends the whole route `good_ping` to the threadpool, where a worker thread will run the function 168 | 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) 169 | - Independently of main thread (i.e. our FastAPI app), 170 | worker thread will be waiting for `time.sleep` to finish. 171 | - Sync operation blocks only the side thread, not the main one. 172 | 4. When `good_ping` finishes its work, server returns a response to the client 173 | 3. `GET /perfect-ping` 174 | 1. FastAPI server receives a request and starts handling it 175 | 2. FastAPI awaits `asyncio.sleep(10)` 176 | 3. Event loop selects next tasks from the queue and works on them (e.g. accept new request, call db) 177 | 4. When `asyncio.sleep(10)` is done, servers finishes the execution of the route and returns a response to the client 178 | 179 | > [!WARNING] 180 | > Notes on the thread pool: 181 | > - Threads require more resources than coroutines, so they are not as cheap as async I/O operations. 182 | > - 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) 183 | 184 | ### CPU Intensive Tasks 185 | 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). 186 | - Awaiting CPU-intensive tasks (e.g. heavy calculations, data processing, video transcoding) is worthless since the CPU has to work to finish the tasks, 187 | 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. 188 | - Running CPU-intensive tasks in other threads also isn't effective, because of [GIL](https://realpython.com/python-gil/). 189 | In short, GIL allows only one thread to work at a time, which makes it useless for CPU tasks. 190 | - If you want to optimize CPU intensive tasks you should send them to workers in another process. 191 | 192 | **Related StackOverflow questions of confused users** 193 | 1. https://stackoverflow.com/questions/62976648/architecture-flask-vs-fastapi/70309597#70309597 194 | - Here you can also check [my answer](https://stackoverflow.com/a/70309597/6927498) 195 | 2. https://stackoverflow.com/questions/65342833/fastapi-uploadfile-is-slow-compared-to-flask 196 | 3. https://stackoverflow.com/questions/71516140/fastapi-runs-api-calls-in-serial-instead-of-parallel-fashion 197 | 198 | ## Pydantic 199 | ### Excessively use Pydantic 200 | Pydantic has a rich set of features to validate and transform data. 201 | 202 | In addition to regular features like required & non-required fields with default values, 203 | Pydantic has built-in comprehensive data processing tools like regex, enums, strings manipulation, emails validation, etc. 204 | ```python 205 | from enum import Enum 206 | from pydantic import AnyUrl, BaseModel, EmailStr, Field 207 | 208 | 209 | class MusicBand(str, Enum): 210 | AEROSMITH = "AEROSMITH" 211 | QUEEN = "QUEEN" 212 | ACDC = "AC/DC" 213 | 214 | 215 | class UserBase(BaseModel): 216 | first_name: str = Field(min_length=1, max_length=128) 217 | username: str = Field(min_length=1, max_length=128, pattern="^[A-Za-z0-9-_]+$") 218 | email: EmailStr 219 | age: int = Field(ge=18, default=None) # must be greater or equal to 18 220 | favorite_band: MusicBand | None = None # only "AEROSMITH", "QUEEN", "AC/DC" values are allowed to be inputted 221 | website: AnyUrl | None = None 222 | ``` 223 | ### Custom Base Model 224 | Having a controllable global base model allows us to customize all the models within the app. For instance, we can enforce a standard datetime format or introduce a common method for all subclasses of the base model. 225 | ```python 226 | from datetime import datetime 227 | from zoneinfo import ZoneInfo 228 | 229 | from fastapi.encoders import jsonable_encoder 230 | from pydantic import BaseModel, ConfigDict 231 | 232 | 233 | def datetime_to_gmt_str(dt: datetime) -> str: 234 | if not dt.tzinfo: 235 | dt = dt.replace(tzinfo=ZoneInfo("UTC")) 236 | 237 | return dt.strftime("%Y-%m-%dT%H:%M:%S%z") 238 | 239 | 240 | class CustomModel(BaseModel): 241 | model_config = ConfigDict( 242 | json_encoders={datetime: datetime_to_gmt_str}, 243 | populate_by_name=True, 244 | ) 245 | 246 | def serializable_dict(self, **kwargs): 247 | """Return a dict which contains only serializable fields.""" 248 | default_dict = self.model_dump() 249 | 250 | return jsonable_encoder(default_dict) 251 | 252 | 253 | ``` 254 | In the example above, we have decided to create a global base model that: 255 | - Serializes all datetime fields to a standard format with an explicit timezone 256 | - Provides a method to return a dict with only serializable fields 257 | ### Decouple Pydantic BaseSettings 258 | 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. 259 | ```python 260 | # src.auth.config 261 | from datetime import timedelta 262 | 263 | from pydantic_settings import BaseSettings 264 | 265 | 266 | class AuthConfig(BaseSettings): 267 | JWT_ALG: str 268 | JWT_SECRET: str 269 | JWT_EXP: int = 5 # minutes 270 | 271 | REFRESH_TOKEN_KEY: str 272 | REFRESH_TOKEN_EXP: timedelta = timedelta(days=30) 273 | 274 | SECURE_COOKIES: bool = True 275 | 276 | 277 | auth_settings = AuthConfig() 278 | 279 | 280 | # src.config 281 | from pydantic import PostgresDsn, RedisDsn, model_validator 282 | from pydantic_settings import BaseSettings 283 | 284 | from src.constants import Environment 285 | 286 | 287 | class Config(BaseSettings): 288 | DATABASE_URL: PostgresDsn 289 | REDIS_URL: RedisDsn 290 | 291 | SITE_DOMAIN: str = "myapp.com" 292 | 293 | ENVIRONMENT: Environment = Environment.PRODUCTION 294 | 295 | SENTRY_DSN: str | None = None 296 | 297 | CORS_ORIGINS: list[str] 298 | CORS_ORIGINS_REGEX: str | None = None 299 | CORS_HEADERS: list[str] 300 | 301 | APP_VERSION: str = "1.0" 302 | 303 | 304 | settings = Config() 305 | 306 | ``` 307 | 308 | ## Dependencies 309 | ### Beyond Dependency Injection 310 | Pydantic is a great schema validator, but for complex validations that involve calling a database or external services, it is not sufficient. 311 | 312 | FastAPI documentation mostly presents dependencies as DI for endpoints, but they are also excellent for request validation. 313 | 314 | Dependencies can be used to validate data against database constraints (e.g., checking if an email already exists, ensuring a user is found, etc.). 315 | ```python 316 | # dependencies.py 317 | async def valid_post_id(post_id: UUID4) -> dict[str, Any]: 318 | post = await service.get_by_id(post_id) 319 | if not post: 320 | raise PostNotFound() 321 | 322 | return post 323 | 324 | 325 | # router.py 326 | @router.get("/posts/{post_id}", response_model=PostResponse) 327 | async def get_post_by_id(post: dict[str, Any] = Depends(valid_post_id)): 328 | return post 329 | 330 | 331 | @router.put("/posts/{post_id}", response_model=PostResponse) 332 | async def update_post( 333 | update_data: PostUpdate, 334 | post: dict[str, Any] = Depends(valid_post_id), 335 | ): 336 | updated_post = await service.update(id=post["id"], data=update_data) 337 | return updated_post 338 | 339 | 340 | @router.get("/posts/{post_id}/reviews", response_model=list[ReviewsResponse]) 341 | async def get_post_reviews(post: dict[str, Any] = Depends(valid_post_id)): 342 | post_reviews = await reviews_service.get_by_post_id(post["id"]) 343 | return post_reviews 344 | ``` 345 | If we didn't put data validation to dependency, we would have to validate `post_id` exists 346 | for every endpoint and write the same tests for each of them. 347 | 348 | ### Chain Dependencies 349 | Dependencies can use other dependencies and avoid code repetition for similar logic. 350 | ```python 351 | # dependencies.py 352 | from fastapi.security import OAuth2PasswordBearer 353 | from jose import JWTError, jwt 354 | 355 | async def valid_post_id(post_id: UUID4) -> dict[str, Any]: 356 | post = await service.get_by_id(post_id) 357 | if not post: 358 | raise PostNotFound() 359 | 360 | return post 361 | 362 | 363 | async def parse_jwt_data( 364 | token: str = Depends(OAuth2PasswordBearer(tokenUrl="/auth/token")) 365 | ) -> dict[str, Any]: 366 | try: 367 | payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"]) 368 | except JWTError: 369 | raise InvalidCredentials() 370 | 371 | return {"user_id": payload["id"]} 372 | 373 | 374 | async def valid_owned_post( 375 | post: dict[str, Any] = Depends(valid_post_id), 376 | token_data: dict[str, Any] = Depends(parse_jwt_data), 377 | ) -> dict[str, Any]: 378 | if post["creator_id"] != token_data["user_id"]: 379 | raise UserNotOwner() 380 | 381 | return post 382 | 383 | # router.py 384 | @router.get("/users/{user_id}/posts/{post_id}", response_model=PostResponse) 385 | async def get_user_post(post: dict[str, Any] = Depends(valid_owned_post)): 386 | return post 387 | 388 | ``` 389 | ### Decouple & Reuse dependencies. Dependency calls are cached 390 | Dependencies can be reused multiple times, and they won't be recalculated - FastAPI caches dependency's result within a request's scope by default, 391 | i.e. if `valid_post_id` gets called multiple times in one route, it will be called only once. 392 | 393 | Knowing this, we can decouple dependencies onto multiple smaller functions that operate on a smaller domain and are easier to reuse in other routes. 394 | For example, in the code below we are using `parse_jwt_data` three times: 395 | 1. `valid_owned_post` 396 | 2. `valid_active_creator` 397 | 3. `get_user_post`, 398 | 399 | but `parse_jwt_data` is called only once, in the very first call. 400 | 401 | ```python 402 | # dependencies.py 403 | from fastapi import BackgroundTasks 404 | from fastapi.security import OAuth2PasswordBearer 405 | from jose import JWTError, jwt 406 | 407 | async def valid_post_id(post_id: UUID4) -> Mapping: 408 | post = await service.get_by_id(post_id) 409 | if not post: 410 | raise PostNotFound() 411 | 412 | return post 413 | 414 | 415 | async def parse_jwt_data( 416 | token: str = Depends(OAuth2PasswordBearer(tokenUrl="/auth/token")) 417 | ) -> dict: 418 | try: 419 | payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"]) 420 | except JWTError: 421 | raise InvalidCredentials() 422 | 423 | return {"user_id": payload["id"]} 424 | 425 | 426 | async def valid_owned_post( 427 | post: Mapping = Depends(valid_post_id), 428 | token_data: dict = Depends(parse_jwt_data), 429 | ) -> Mapping: 430 | if post["creator_id"] != token_data["user_id"]: 431 | raise UserNotOwner() 432 | 433 | return post 434 | 435 | 436 | async def valid_active_creator( 437 | token_data: dict = Depends(parse_jwt_data), 438 | ): 439 | user = await users_service.get_by_id(token_data["user_id"]) 440 | if not user["is_active"]: 441 | raise UserIsBanned() 442 | 443 | if not user["is_creator"]: 444 | raise UserNotCreator() 445 | 446 | return user 447 | 448 | 449 | # router.py 450 | @router.get("/users/{user_id}/posts/{post_id}", response_model=PostResponse) 451 | async def get_user_post( 452 | worker: BackgroundTasks, 453 | post: Mapping = Depends(valid_owned_post), 454 | user: Mapping = Depends(valid_active_creator), 455 | ): 456 | """Get post that belong the active user.""" 457 | worker.add_task(notifications_service.send_email, user["id"]) 458 | return post 459 | 460 | ``` 461 | 462 | ### Prefer `async` dependencies 463 | 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. 464 | 465 | 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. 466 | 467 | [See more](https://github.com/Kludex/fastapi-tips?tab=readme-ov-file#9-your-dependencies-may-be-running-on-threads) (external link) 468 | 469 | 470 | ## Miscellaneous 471 | ### Follow the REST 472 | Developing RESTful API makes it easier to reuse dependencies in routes like these: 473 | 1. `GET /courses/:course_id` 474 | 2. `GET /courses/:course_id/chapters/:chapter_id/lessons` 475 | 3. `GET /chapters/:chapter_id` 476 | 477 | The only caveat is having to use the same variable names in the path: 478 | - If you have two endpoints `GET /profiles/:profile_id` and `GET /creators/:creator_id` 479 | that both validate whether the given `profile_id` exists, but `GET /creators/:creator_id` 480 | also checks if the profile is creator, then it's better to rename `creator_id` path variable to `profile_id` and chain those two dependencies. 481 | ```python 482 | # src.profiles.dependencies 483 | async def valid_profile_id(profile_id: UUID4) -> Mapping: 484 | profile = await service.get_by_id(profile_id) 485 | if not profile: 486 | raise ProfileNotFound() 487 | 488 | return profile 489 | 490 | # src.creators.dependencies 491 | async def valid_creator_id(profile: Mapping = Depends(valid_profile_id)) -> Mapping: 492 | if not profile["is_creator"]: 493 | raise ProfileNotCreator() 494 | 495 | return profile 496 | 497 | # src.profiles.router.py 498 | @router.get("/profiles/{profile_id}", response_model=ProfileResponse) 499 | async def get_user_profile_by_id(profile: Mapping = Depends(valid_profile_id)): 500 | """Get profile by id.""" 501 | return profile 502 | 503 | # src.creators.router.py 504 | @router.get("/creators/{profile_id}", response_model=ProfileResponse) 505 | async def get_user_profile_by_id( 506 | creator_profile: Mapping = Depends(valid_creator_id) 507 | ): 508 | """Get creator's profile by id.""" 509 | return creator_profile 510 | 511 | ``` 512 | ### FastAPI response serialization 513 | You may think you can return Pydantic object that matches your route's `response_model` to make some optimizations, 514 | but you'd be wrong. 515 | 516 | FastAPI first converts that pydantic object to dict with its `jsonable_encoder`, then validates 517 | data with your `response_model`, and only then serializes your object to JSON. 518 | 519 | This means your Pydantic model object is created twice: 520 | - First, when you explicitly create it to return from your route. 521 | - Second, implicitly by FastAPI to validate the response data according to the response_model. 522 | 523 | ```python 524 | from fastapi import FastAPI 525 | from pydantic import BaseModel, root_validator 526 | 527 | app = FastAPI() 528 | 529 | 530 | class ProfileResponse(BaseModel): 531 | @model_validator(mode="after") 532 | def debug_usage(self): 533 | print("created pydantic model") 534 | 535 | return self 536 | 537 | 538 | @app.get("/", response_model=ProfileResponse) 539 | async def root(): 540 | return ProfileResponse() 541 | ``` 542 | **Logs Output:** 543 | ``` 544 | [INFO] [2022-08-28 12:00:00.000000] created pydantic model 545 | [INFO] [2022-08-28 12:00:00.000020] created pydantic model 546 | ``` 547 | 548 | ### If you must use sync SDK, then run it in a thread pool. 549 | If you must use a library to interact with external services, and it's not `async`, 550 | then make the HTTP calls in an external worker thread. 551 | 552 | We can use the well-known `run_in_threadpool` from starlette. 553 | ```python 554 | from fastapi import FastAPI 555 | from fastapi.concurrency import run_in_threadpool 556 | from my_sync_library import SyncAPIClient 557 | 558 | app = FastAPI() 559 | 560 | 561 | @app.get("/") 562 | async def call_my_sync_library(): 563 | my_data = await service.get_my_data() 564 | 565 | client = SyncAPIClient() 566 | await run_in_threadpool(client.make_request, data=my_data) 567 | ``` 568 | 569 | ### ValueErrors might become Pydantic ValidationError 570 | 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. 571 | ```python 572 | # src.profiles.schemas 573 | from pydantic import BaseModel, field_validator 574 | 575 | class ProfileCreate(BaseModel): 576 | username: str 577 | 578 | @field_validator("password", mode="after") 579 | @classmethod 580 | def valid_password(cls, password: str) -> str: 581 | if not re.match(STRONG_PASSWORD_PATTERN, password): 582 | raise ValueError( 583 | "Password must contain at least " 584 | "one lower character, " 585 | "one upper character, " 586 | "digit or " 587 | "special symbol" 588 | ) 589 | 590 | return password 591 | 592 | 593 | # src.profiles.routes 594 | from fastapi import APIRouter 595 | 596 | router = APIRouter() 597 | 598 | 599 | @router.post("/profiles") 600 | async def get_creator_posts(profile_data: ProfileCreate): 601 | pass 602 | ``` 603 | **Response Example:** 604 | 605 | 606 | 607 | ### Docs 608 | 1. Unless your API is public, hide docs by default. Show it explicitly on the selected envs only. 609 | ```python 610 | from fastapi import FastAPI 611 | from starlette.config import Config 612 | 613 | config = Config(".env") # parse .env file for env variables 614 | 615 | ENVIRONMENT = config("ENVIRONMENT") # get current env name 616 | SHOW_DOCS_ENVIRONMENT = ("local", "staging") # explicit list of allowed envs 617 | 618 | app_configs = {"title": "My Cool API"} 619 | if ENVIRONMENT not in SHOW_DOCS_ENVIRONMENT: 620 | app_configs["openapi_url"] = None # set url for docs as null 621 | 622 | app = FastAPI(**app_configs) 623 | ``` 624 | 2. Help FastAPI to generate an easy-to-understand docs 625 | 1. Set `response_model`, `status_code`, `description`, etc. 626 | 2. If models and statuses vary, use `responses` route attribute to add docs for different responses 627 | ```python 628 | from fastapi import APIRouter, status 629 | 630 | router = APIRouter() 631 | 632 | @router.post( 633 | "/endpoints", 634 | response_model=DefaultResponseModel, # default response pydantic model 635 | status_code=status.HTTP_201_CREATED, # default status code 636 | description="Description of the well documented endpoint", 637 | tags=["Endpoint Category"], 638 | summary="Summary of the Endpoint", 639 | responses={ 640 | status.HTTP_200_OK: { 641 | "model": OkResponse, # custom pydantic model for 200 response 642 | "description": "Ok Response", 643 | }, 644 | status.HTTP_201_CREATED: { 645 | "model": CreatedResponse, # custom pydantic model for 201 response 646 | "description": "Creates something from user request ", 647 | }, 648 | status.HTTP_202_ACCEPTED: { 649 | "model": AcceptedResponse, # custom pydantic model for 202 response 650 | "description": "Accepts request and handles it later", 651 | }, 652 | }, 653 | ) 654 | async def documented_route(): 655 | pass 656 | ``` 657 | Will generate docs like this: 658 | ![FastAPI Generated Custom Response Docs](images/custom_responses.png "Custom Response Docs") 659 | 660 | ### Set DB keys naming conventions 661 | Explicitly setting the indexes' namings according to your database's convention is preferable over sqlalchemy's. 662 | ```python 663 | from sqlalchemy import MetaData 664 | 665 | POSTGRES_INDEXES_NAMING_CONVENTION = { 666 | "ix": "%(column_0_label)s_idx", 667 | "uq": "%(table_name)s_%(column_0_name)s_key", 668 | "ck": "%(table_name)s_%(constraint_name)s_check", 669 | "fk": "%(table_name)s_%(column_0_name)s_fkey", 670 | "pk": "%(table_name)s_pkey", 671 | } 672 | metadata = MetaData(naming_convention=POSTGRES_INDEXES_NAMING_CONVENTION) 673 | ``` 674 | ### Migrations. Alembic 675 | 1. Migrations must be static and revertable. 676 | If your migrations depend on dynamically generated data, then 677 | make sure the only thing that is dynamic is the data itself, not its structure. 678 | 2. Generate migrations with descriptive names & slugs. Slug is required and should explain the changes. 679 | 3. Set human-readable file template for new migrations. We use `*date*_*slug*.py` pattern, e.g. `2022-08-24_post_content_idx.py` 680 | ``` 681 | # alembic.ini 682 | file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s 683 | ``` 684 | ### Set DB naming conventions 685 | Being consistent with names is important. Some rules we followed: 686 | 1. lower_case_snake 687 | 2. singular form (e.g. `post`, `post_like`, `user_playlist`) 688 | 3. group similar tables with module prefix, e.g. `payment_account`, `payment_bill`, `post`, `post_like` 689 | 4. stay consistent across tables, but concrete namings are ok, e.g. 690 | 1. use `profile_id` in all tables, but if some of them need only profiles that are creators, use `creator_id` 691 | 2. use `post_id` for all abstract tables like `post_like`, `post_view`, but use concrete naming in relevant modules like `course_id` in `chapters.course_id` 692 | 5. `_at` suffix for datetime 693 | 6. `_date` suffix for date 694 | ### SQL-first. Pydantic-second 695 | - Usually, database handles data processing much faster and cleaner than CPython will ever do. 696 | - It's preferable to do all the complex joins and simple data manipulations with SQL. 697 | - It's preferable to aggregate JSONs in DB for responses with nested objects. 698 | ```python 699 | # src.posts.service 700 | from typing import Any 701 | 702 | from pydantic import UUID4 703 | from sqlalchemy import desc, func, select, text 704 | from sqlalchemy.sql.functions import coalesce 705 | 706 | from src.database import database, posts, profiles, post_review, products 707 | 708 | async def get_posts( 709 | creator_id: UUID4, *, limit: int = 10, offset: int = 0 710 | ) -> list[dict[str, Any]]: 711 | select_query = ( 712 | select( 713 | ( 714 | posts.c.id, 715 | posts.c.slug, 716 | posts.c.title, 717 | func.json_build_object( 718 | text("'id', profiles.id"), 719 | text("'first_name', profiles.first_name"), 720 | text("'last_name', profiles.last_name"), 721 | text("'username', profiles.username"), 722 | ).label("creator"), 723 | ) 724 | ) 725 | .select_from(posts.join(profiles, posts.c.owner_id == profiles.c.id)) 726 | .where(posts.c.owner_id == creator_id) 727 | .limit(limit) 728 | .offset(offset) 729 | .group_by( 730 | posts.c.id, 731 | posts.c.type, 732 | posts.c.slug, 733 | posts.c.title, 734 | profiles.c.id, 735 | profiles.c.first_name, 736 | profiles.c.last_name, 737 | profiles.c.username, 738 | profiles.c.avatar, 739 | ) 740 | .order_by( 741 | desc(coalesce(posts.c.updated_at, posts.c.published_at, posts.c.created_at)) 742 | ) 743 | ) 744 | 745 | return await database.fetch_all(select_query) 746 | 747 | # src.posts.schemas 748 | from typing import Any 749 | 750 | from pydantic import BaseModel, UUID4 751 | 752 | 753 | class Creator(BaseModel): 754 | id: UUID4 755 | first_name: str 756 | last_name: str 757 | username: str 758 | 759 | 760 | class Post(BaseModel): 761 | id: UUID4 762 | slug: str 763 | title: str 764 | creator: Creator 765 | 766 | 767 | # src.posts.router 768 | from fastapi import APIRouter, Depends 769 | 770 | router = APIRouter() 771 | 772 | 773 | @router.get("/creators/{creator_id}/posts", response_model=list[Post]) 774 | async def get_creator_posts(creator: dict[str, Any] = Depends(valid_creator_id)): 775 | posts = await service.get_posts(creator["id"]) 776 | 777 | return posts 778 | ``` 779 | ### Set tests client async from day 0 780 | Writing integration tests with DB will most likely lead to messed up event loop errors in the future. 781 | Set the async test client immediately, e.g. [httpx](https://github.com/encode/starlette/issues/652) 782 | ```python 783 | import pytest 784 | from async_asgi_testclient import TestClient 785 | 786 | from src.main import app # inited FastAPI app 787 | 788 | 789 | @pytest.fixture 790 | async def client() -> AsyncGenerator[TestClient, None]: 791 | host, port = "127.0.0.1", "9000" 792 | 793 | async with AsyncClient(transport=ASGITransport(app=app, client=(host, port)), base_url="http://test") as client: 794 | yield client 795 | 796 | 797 | @pytest.mark.asyncio 798 | async def test_create_post(client: TestClient): 799 | resp = await client.post("/posts") 800 | 801 | assert resp.status_code == 201 802 | ``` 803 | Unless you have sync db connections (excuse me?) or aren't planning to write integration tests. 804 | 805 | ### Use ruff 806 | With linters, you can forget about formatting the code and focus on writing the business logic. 807 | 808 | [Ruff](https://github.com/astral-sh/ruff) is "blazingly-fast" new linter that replaces black, autoflake, isort, and supports more than 600 lint rules. 809 | 810 | It's a popular good practice to use pre-commit hooks, but just using the script was ok for us. 811 | ```shell 812 | #!/bin/sh -e 813 | set -x 814 | 815 | ruff check --fix src 816 | ruff format src 817 | ``` 818 | 819 | ## Bonus Section 820 | Some very kind people shared their own experience and best practices that are definitely worth reading. 821 | Check them out at [issues](https://github.com/zhanymkanov/fastapi-best-practices/issues) section of the project. 822 | 823 | For instance, [lowercase00](https://github.com/zhanymkanov/fastapi-best-practices/issues/4) 824 | has described in details their best practices working with permissions & auth, class-based services & views, 825 | task queues, custom response serializers, configuration with dynaconf, etc. 826 | 827 | If you have something to share about your experience working with FastAPI, whether it's good or bad, 828 | you are very welcome to create a new issue. It is our pleasure to read it. 829 | --------------------------------------------------------------------------------