Technical Decision-Making
Technical leads choose among imperfect options under uncertainty. Ranked decisions make trade-offs explicit, document wrong choices, and set review triggers so Python stack choices (FastAPI vs Django, sync vs async) do not become permanent religion.
How to Use This List
- Walk decisions in order when planning architecture or upgrades.
- Record the chosen rank and why in an ADR or ticket.
- Set a review trigger (scale, team size, SLO miss) for reversible calls.
- Use spikes to de-risk "2nd" options before committing to "Best."
- Revisit when trigger fires - supersede ADR, do not silently drift.
Decision 1: HTTP framework for a new Python API
Scenario: Greenfield B2B API, 6 engineers, SLO p95 200ms, Postgres, need OpenAPI.
| Rank | Choice | Approach |
|---|---|---|
| Best | FastAPI | Async routes, Pydantic v2, OpenAPI native, httpx test client |
| 2nd | Django + DRF | Admin, ORM maturity, hiring pool; heavier per request |
| 3rd | Flask | Minimal; compose extensions; fewer batteries |
Wrong choice: Flask for 40+ endpoints without discipline - blueprint sprawl and inconsistent validation.
Why best is best: Team SLO and typed contracts favor FastAPI; shared internal auth plugin already exists.
Decision 2: Sync Gunicorn vs async Uvicorn workers
Scenario: CRUD API, mostly Postgres I/O, occasional CPU on PDF generation.
| Rank | Choice | Approach |
|---|---|---|
| Best | Sync Gunicorn + thread pool for CPU | Simple mental model; SQLAlchemy sync well trodden |
| 2nd | Async Uvicorn | If WebSockets or high fan-out concurrent I/O |
| 3rd | Sync + gevent | Legacy; debugging harder |
Wrong choice: Async everywhere with sync ORM in routes - event-loop blocking outages.
Why best is best: Workload is I/O bound but team knows sync SQLAlchemy; CPU bits isolated to ProcessPoolExecutor.
Decision 3: Monolith vs split services
Scenario: 8 engineers, one product, shared Postgres, deploy weekly.
| Rank | Choice | Approach |
|---|---|---|
| Best | Modular monolith | Clear package boundaries; single deploy; extract later |
| 2nd | Two services (API + workers) | When Celery scale and failure domain differ |
| 3rd | Microservices per domain | Premature ops tax |
Wrong choice: Five microservices before second team exists - CFR and lead time suffer.
Why best is best: Team size and transaction boundaries fit one deployable with modules.
Decision 4: SQLAlchemy sync vs async engine
Scenario: FastAPI service considering async SQLAlchemy 2.0.
| Rank | Choice | Approach |
|---|---|---|
| Best | Sync engine + thread pool | If team new to async DB; fewer pool surprises |
| 2nd | Async engine (asyncpg) | High concurrent read I/O, trained team |
| 3rd | Raw SQL only | Rare; loses ORM productivity |
Wrong choice: Async engine with blocking calls in dependencies - pool exhaustion masked as latency.
Why best is best: Squad maturity and moderate concurrency do not justify async DB complexity yet.
Decision 5: Celery vs in-process background tasks
Scenario: Send email, generate PDF, nightly billing - minutes-long tasks.
| Rank | Choice | Approach |
|---|---|---|
| Best | Celery + Redis broker | Retries, visibility, scale workers independently |
| 2nd | FastAPI BackgroundTasks | Only for <2s fire-and-forget |
| 3rd | Dramatiq / RQ | Smaller ops footprint; fewer features |
Wrong choice: BackgroundTasks for billing batch - kills API workers under load.
Why best is best: Task duration and retry policy need a real queue with DLQ.
Decision 6: Pydantic settings vs custom env parser
Scenario: Twelve-factor config across dev/staging/prod.
| Rank | Choice | Approach |
|---|---|---|
| Best | pydantic-settings | Typed, validated boot; .env for local only |
| 2nd | python-decouple | Lightweight; less validation |
| 3rd | os.environ manual | Error-prone at scale |
Wrong choice: Scattered os.getenv without validation - prod boots with empty DATABASE_URL.
Why best is best: Fail-fast validation matches fleet standards; pairs with Pydantic models elsewhere.
Decision 7: Type checking strictness in CI
Scenario: 200k LOC mixed typed/untyped monolith.
| Rank | Choice | Approach |
|---|---|---|
| Best | Gradual pyright on changed paths | reportMissingImports strict in CI for touched packages |
| 2nd | mypy strict module-by-module | Slower; good for libraries |
| 3rd | No CI types | Debt compounds |
Wrong choice: Flip entire repo to strict overnight - months of churn blocks delivery.
Why best is best: Changed-path gate improves quality without stopping feature work.
Decision 8: Packaging with uv vs Poetry
Scenario: New service repo, team standardizing toolchain.
| Rank | Choice | Approach |
|---|---|---|
| Best | uv + pyproject.toml + lock | Fast CI, pip-compatible, modern default |
| 2nd | Poetry | Mature; slower CI; team already expert |
| 3rd | pip-tools only | Works; less ergonomic dev UX |
Wrong choice: No lockfile in prod images - dependency drift incidents.
Why best is best: Org moving to uv 0.6+; lock hash in image labels.
FAQs
How many options per decision?
Three ranked options plus explicit wrong choice is enough; more creates analysis paralysis.
When run a spike?
When two options score within 10% weighted - time-box 2-3 days with success criteria.
Who breaks ties?
Tech lead or architect documents decision; EM weighs schedule risk only.
How reversible are these decisions?
Framework and monolith splits are costly - set review triggers. Config and flags are cheap.
Do customers vote?
Product input on outcomes; engineering owns technical ranks.
How document?
Link to Architecture Decision Records (ADRs) for persistent log.
What about build vs buy?
Same ranked table: build, buy, defer with wrong-choice callout on premature build.
How handle urgent incidents?
IC may short-circuit process; follow up with retro ADR within a week.
Python version upgrades?
Rank: in-place minor bump vs side-by-side deploy vs delay; tie to EOL policy.
When revisit a decision?
When review trigger hits - traffic 2x, team +50%, or repeated incident class.
Related
- Architecture Decision Records (ADRs) - persist choices
- Running Design Reviews - socialize options
- Handling Technical Disagreements - disagree and commit
- Python Architecture Decisions - pattern catalog
- Leadership Best Practices - squad habits
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.