โ† How the Web Works 02 / 18 Next: FastAPI โ†’

Python Async

Module 1 ยท Foundation ยท โฑ ~25 min
Why this matters: Every single function in the JongYang backend starts with 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:

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):

python โ€” blocking (DO NOT use this in FastAPI)
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):

python โ€” async (what FastAPI uses)
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:

Mental model: 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:

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:

python โ€” backend/app/routers/public/bookings.py (excerpt)
@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:

python โ€” backend/app/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:

python โ€” backend/seed/dummy_shops.py (bottom)
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:

python โ€” WRONG (causes MissingGreenlet error)
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
python โ€” CORRECT fix (direct insert into association table)
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.

Common async mistakes:
โ€ข 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?

Because FastAPI uses an async event loop (via uvicorn/ASGI). Using 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?

You get a coroutine object, not query results. The database query never actually runs. You'd be assigning a coroutine to 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?

By switching between coroutines whenever one hits 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?

SQLAlchemy's lazy loading is synchronous โ€” accessing a relationship attribute triggers a synchronous database query. In an async session, this causes a 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?

It starts the asyncio event loop, runs the 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.