Python Async
async def and every database call uses await. If you don't understand what these do, you'll be confused by the most basic code in the project.
The Problem: Blocking I/O
Imagine a restaurant with a single waiter. A customer orders food. The waiter walks to the kitchen and stands there staring at the window until the food is ready โ 8 minutes later. Meanwhile, no other customers can be served.
This is synchronous (blocking) I/O. The program pauses and does nothing while waiting for the slow operation to complete. In a web server:
- Each database query takes ~10ms
- Each HTTP call to Omise takes ~300ms
- If the server can only handle one request at a time, 100 customers waiting = the last customer waits 30 seconds
A smarter waiter takes orders from table 1, goes to the kitchen and puts in the order, then comes back and takes orders from table 2 while table 1's food is cooking. This is asynchronous I/O.
Synchronous vs Async Code
Here's synchronous Python code (blocks while waiting):
import time
def get_shop_from_db(slug):
time.sleep(0.01) # simulates a 10ms database query
return {"name": "BKK Sports Club"}
def get_availability_from_db(resource_id):
time.sleep(0.01) # another 10ms query
return ["07:00", "08:00", "09:00"]
# This takes 20ms total โ the program is BLOCKED during both sleeps
shop = get_shop_from_db("bkk-sports-club")
slots = get_availability_from_db("court-a-uuid")
print(shop, slots)
Here's the async version (frees the thread while waiting):
import asyncio
async def get_shop_from_db(slug):
await asyncio.sleep(0.01) # yields control while waiting
return {"name": "BKK Sports Club"}
async def get_availability_from_db(resource_id):
await asyncio.sleep(0.01)
return ["07:00", "08:00", "09:00"]
async def main():
# Run both queries CONCURRENTLY โ total time ~10ms, not 20ms
shop, slots = await asyncio.gather(
get_shop_from_db("bkk-sports-club"),
get_availability_from_db("court-a-uuid"),
)
print(shop, slots)
asyncio.run(main())
async def and await
Two keywords change everything:
async defโ marks a function as a "coroutine". You cannot call it normally; you mustawaitit.awaitโ pauses this coroutine and gives control back to the event loop until the awaited thing is done. The event loop can then run other coroutines.
await is like saying "I'm going to wait for this, so go do something else in the meantime." When the result is ready, come back to me and I'll continue from here.
Rules:
- You can only use
awaitinside anasync deffunction - An
async deffunction returns a coroutine object โ it doesn't run until youawaitit - You must await async functions. Forgetting the
awaitis a common bug โ the function just returns a coroutine object that never runs
The Event Loop
Python runs async code on a single thread using an event loop. The loop juggles many coroutines by switching between them whenever one is waiting:
EVENT LOOP (single thread)
Time โ 0ms 10ms 20ms 30ms
โ โ โ โ
Request A โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ (DB query 10ms, then processing)
Request B โโโโโโโโโโโโโโโโโโโโโโโโโโโ
Request C โโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโ = CPU work (running Python code)
โโโโ = I/O wait (awaiting DB/HTTP โ loop serves other requests)
All 3 requests complete in ~30ms instead of ~60ms (sequential)
The loop itself never blocks. When a coroutine hits await, the loop picks up the next ready coroutine. This is why a single FastAPI server can handle hundreds of simultaneous requests on one CPU core.
Real Project Code: create_booking
Here's the actual booking creation function from routers/public/bookings.py. Notice how many await calls there are โ each one is an I/O wait where other requests can run:
@router.post("", response_model=BookingOut, status_code=201)
async def create_booking(body: BookingCreate, session: AsyncSession = Depends(get_db)):
# โ await โ database read (event loop can serve other requests here)
resource = (await session.execute(
select(Resource).where(Resource.id == body.resource_id, Resource.is_active == True)
)).scalar_one_or_none()
# โก await โ another database read
shop = (await session.execute(
select(Shop).where(Shop.id == resource.shop_id, Shop.is_active == True)
)).scalar_one_or_none()
# โข await โ availability check queries (more DB reads)
available = await get_available_slots(
session, body.resource_id, body.booking_date, duration_min, shop.slot_increment_min
)
# โฃ await โ write to database
session.add(booking)
await session.commit() # this is when the DB write actually happens
await session.refresh(booking) # re-reads the row to get server-generated values
# โค await โ external HTTP call to Omise (slowest step, ~300ms)
charge = await create_promptpay_charge(float(total_price), {...})
return out # returns immediately, no await needed
While step โค waits 300ms for Omise to respond, the event loop can process dozens of other requests from other users.
async with httpx.AsyncClient
When calling external APIs (Omise, LINE), we use httpx.AsyncClient โ an async HTTP client. From services/omise.py:
async def create_promptpay_charge(amount_thb: float, metadata: dict) -> dict:
amount_satang = round(amount_thb * 100)
async with httpx.AsyncClient() as client: # opens a connection pool
r = await client.post( # awaits the HTTP response
"https://api.omise.co/charges",
auth=(settings.OMISE_SECRET_KEY, ""),
json={
"amount": amount_satang,
"currency": "thb",
"source": {"type": "promptpay"},
"metadata": metadata,
},
)
r.raise_for_status() # raises exception if status >= 400
data = r.json()
return {"charge_id": data["id"], "qr_url": data["source"]["scannable_code"]["image"]["download_uri"]}
async with is like with but for async context managers. It opens the HTTP client, runs the body, then properly closes the connection โ even if an exception occurs.
asyncio.run() โ The Entry Point
You can't just call an async function from normal Python. You need to start the event loop. The seed script uses this:
async def main() -> None:
async with AsyncSessionLocal() as session:
await seed(session)
if __name__ == "__main__":
asyncio.run(main()) # starts the event loop, runs main(), then stops
FastAPI handles this for you โ when you run uvicorn app.main:app, uvicorn starts and manages the event loop automatically.
The Lazy-Load Bug We Hit
The seed script had this code that caused a crash:
for staff in paw_staff:
staff.services.extend(paw_services) # โ CRASH
# SQLAlchemy tries to lazy-load staff.services from the DB
# But lazy loading is SYNCHRONOUS โ it can't be awaited
# In an async context, this is illegal
await session.execute(insert(resource_services), [
{"resource_id": s.id, "service_id": svc.id}
for s in paw_staff for svc in paw_services
])
# โ
This is explicitly awaited โ no lazy load needed
The lesson: SQLAlchemy async does not support lazy loading. Any time you access a relationship attribute on an ORM object in async code, it tries to make a synchronous DB call, which crashes. You must either eagerly load relationships with selectinload() or insert directly.
โข Forgetting
await before a coroutine call โ the function never runsโข Calling a sync/blocking function inside async code โ blocks the event loop for everyone
โข Accessing SQLAlchemy relationships without eager loading โ causes lazy-load crash
๐ง Self-Check Quiz
1. Why does FastAPI require async def for route handlers instead of regular def?
async def lets the event loop switch to other requests while this handler is waiting for I/O (database queries, HTTP calls). If you use regular def, it blocks the entire event loop โ no other requests can be processed until this one finishes. With hundreds of concurrent users, this would be catastrophic.2. What happens if you write result = session.execute(select(Booking)) without await?
result, and any attempt to use it as query results would fail with a confusing error. Always: result = await session.execute(...)3. The event loop runs on a single thread. How can it handle 100 simultaneous booking requests?
await. When request A awaits a 10ms database query, the loop immediately starts processing request B. When A's query completes, A resumes. The key insight: most web server time is spent waiting for I/O, not actually computing. The event loop fills those waits with other work. True parallelism isn't needed โ interleaving is enough.4. Why did staff.services.extend(paw_services) crash in the seed script?
MissingGreenlet error because there's no event loop context to run the synchronous query in. The fix was to use direct INSERT statements into the resource_services junction table with await session.execute(insert(resource_services), [...]).5. What does asyncio.run(main()) do in the seed script, and why is it needed?
main() coroutine until it completes, then stops the event loop. It's needed because you can't call an async function from regular synchronous Python โ you need an event loop to execute coroutines. FastAPI/uvicorn handle this automatically for the web server, but a standalone script like the seed must do it manually.