The DetachedInstanceError in SQLAlchemy, when encountered within FastAPI async routes, means a database session that was previously associated with an object has been closed or lost, and you’re trying to operate on that object as if the session were still active. This typically happens when you fetch an object within an async request, the session is closed upon completion of that part of the request, and then you try to access or modify that object later in the same request, but the session is no longer available.

Here are the common causes and how to fix them:

  1. Session Closed Prematurely by yield:

    • Diagnosis: You’re likely using yield to manage your SQLAlchemy session within a dependency. If the code after the yield (i.e., the cleanup part) runs before you’ve finished all operations on the session’s objects, you’ll hit this. Check your dependency function.
    • Fix: Ensure all database operations involving your fetched objects are completed before the yield statement in your session dependency, or move the session management logic to ensure the session remains open for the duration of the request. The most common pattern is to yield the session and then commit/rollback/close after the route handler has finished.
      # Example of a problematic pattern (session closed too early)
      @app.get("/items/{item_id}")
      async def read_item(item_id: int, db: Session = Depends(get_db)):
          item = db.query(Item).filter(Item.id == item_id).first()
          # If get_db uses a simple yield and closes immediately after the route handler returns,
          # and you try to access item.owner later (e.g., in a background task or another part of the handler that executes *after* the yield returns), this will fail.
          # The fix is to ensure the session is managed correctly for the *entire* scope of operations.
          return item
      
      # Corrected pattern in get_db:
      def get_db(request: Request):
          db = SessionLocal()
          try:
              yield db
          finally:
              db.close() # This ensures the session is closed *after* the route handler is done.
      
      # If you need to do something *after* the route handler returns but *before* the session closes,
      # you'd need to manage that within the route handler itself, not by altering the dependency's yield timing.
      
    • Why it works: The yield in a dependency function pauses execution, returns the value (the session), and resumes after the route handler has executed its core logic. The code after the yield in the dependency is what runs during cleanup. If your route handler returns quickly and the cleanup code runs before you’re done with the object, the session is closed while the object is still "attached" to it in memory. The correct pattern involves the finally block in the dependency executing after the route handler has fully completed its work.
  2. Session Not Passed Correctly or Re-initialized:

    • Diagnosis: You might be fetching an object with one session, and then later in the same request (perhaps in a different function called by the route handler), you’re trying to operate on that object using a new, unassociated session, or no session at all.
    • Fix: Ensure the same session instance is used throughout the request’s logical flow. If you pass objects between functions, ensure the session context is maintained. Often, this means passing the db: Session object directly, or using a context manager pattern correctly.
      # In your route handler:
      async def process_user_data(user: User, db: Session = Depends(get_db)):
          # Here, 'user' might be an object fetched earlier or passed in.
          # If you try to do something like:
          # new_db = SessionLocal() # Incorrectly creating a new session
          # user.profile.last_login = datetime.utcnow()
          # new_db.commit() # This will fail because 'user' is not associated with 'new_db'
          # Instead, use the 'db' passed in:
          user.profile.last_login = datetime.utcnow()
          db.commit() # Correctly uses the existing session
          db.refresh(user)
          return user
      
    • Why it works: SQLAlchemy’s object-relational mapper tracks the state of objects relative to the session they were loaded or saved with. Using a different session means the object’s state isn’t known or managed by the new session, leading to detachment.
  3. Async Context Management Issues:

    • Diagnosis: If you’re using asynchronous session management (e.g., AsyncSessionLocal with async with), ensure the async with block correctly encompasses all operations that need the session.
    • Fix: Wrap all your database interactions within the async with statement for the session.
      # Assuming get_async_db yields an AsyncSession
      async def get_async_db(request: Request):
          async with AsyncSessionLocal() as db:
              yield db
      
      @app.get("/users/{user_id}")
      async def read_user(user_id: int, db: AsyncSession = Depends(get_async_db)):
          user = await db.execute(select(User).filter(User.id == user_id))
          user_obj = user.scalar_one_or_none()
          
          if user_obj:
              # Any subsequent operations on user_obj must happen *within* the scope of the session
              # that was yielded by get_async_db.
              # If you were to fetch related data in a separate call that doesn't have access to this 'db',
              # you'd get the error.
              # Example:
              # await db.commit() # This is fine, still within the async with block
              # return user_obj # This is fine too, the session context is still active until the handler exits
              
              # If you had something like:
              # background_tasks.add_task(update_user_status, user_obj.id)
              # And update_user_status tried to use a *different* session to fetch user_obj again,
              # it would be detached. The fix is to pass the session or necessary data to the task.
              pass # Placeholder for operations that *must* occur while db is active
          return user_obj
      
    • Why it works: The async with statement for an asynchronous session ensures that the session is properly opened before entering the block and properly closed (or committed/rolled back) upon exiting the block, maintaining the session’s lifecycle for all operations within it.
  4. Background Tasks and Session Lifetimes:

    • Diagnosis: Background tasks (like those in FastAPI) often run after the main request handler has returned. If the background task tries to access an object that was loaded with the request’s session, and that session has since been closed by the dependency’s cleanup, you’ll get DetachedInstanceError.
    • Fix: Pass necessary data (like IDs) to the background task, and have the task acquire its own fresh session to fetch or operate on the data. Alternatively, if the operation is trivial, you might be able to perform it synchronously within the request handler before it returns, but this is often not feasible for longer operations.
      # In your route handler:
      from fastapi import BackgroundTasks
      
      async def update_status_in_background(user_id: int):
          async with AsyncSessionLocal() as db: # Get a new session for the task
              user = await db.get(User, user_id)
              if user:
                  user.status = "processed"
                  await db.commit()
                  await db.refresh(user)
      
      @app.post("/process/{user_id}")
      async def process_user_endpoint(user_id: int, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_async_db)):
          # Fetch user with the request's session
          user = await db.get(User, user_id)
          if not user:
              raise HTTPException(status_code=404, detail="User not found")
          
          # Schedule background task, passing only what's needed
          background_tasks.add_task(update_status_in_background, user_id)
          
          # You can still operate on 'user' here if needed, as 'db' is still active
          user.last_processed_at = datetime.utcnow()
          await db.commit()
          await db.refresh(user)
          
          return {"message": "Processing started in background"}
      
    • Why it works: Each background task operates in its own scope with its own session lifecycle. This prevents it from trying to use a session that has already been closed by the main request.
  5. Detached Objects Explicitly Detached:

    • Diagnosis: Less common, but possible: you might have explicitly called session.expunge(obj) or session.expunge_all(), or the object might have been passed between different sessions where one was closed and the other wasn’t correctly linked.
    • Fix: Review your code for explicit calls to expunge or expunge_all. If you’re manually managing sessions across different parts of your application, ensure that objects are either kept within the same session’s lifecycle or are properly re-attached or re-queried. Usually, you want to avoid explicit expunge unless you have a very specific reason.
      # Example of explicit expunge (usually not needed in web apps)
      @app.get("/items/{item_id}/detached")
      async def get_detached_item(item_id: int, db: Session = Depends(get_db)):
          item = db.query(Item).filter(Item.id == item_id).first()
          db.expunge(item) # Explicitly detach the item from the session
          # Now, any attempt to use 'item' with 'db' will fail if 'db' is closed,
          # or if you try to commit changes to 'item' via 'db' later.
          # If you need to modify 'item' after expunging, you'd typically need to merge it back
          # into a session or create a new object.
          return item
      
    • Why it works: session.expunge() explicitly removes an object from the session’s management. It becomes a plain Python object, no longer tracked or associated with any session.
  6. Object Loaded in One Request, Used in Another:

    • Diagnosis: This is more of an architectural issue. If you load an object in one HTTP request, store it (e.g., in a cache or a long-lived variable), and then try to use it in a subsequent, unrelated HTTP request, it will be detached because the session from the first request is long gone.
    • Fix: Objects loaded from a database session are tied to that session’s lifecycle. If you need to persist data across requests without a continuous session, you should either store plain data (like IDs) and re-fetch the object with a new session in the next request, or use a caching mechanism that understands session management.
      # This pattern is generally discouraged for web requests due to session state.
      # If you fetch an item in request A:
      # item_from_request_A = db_session_A.query(Item).get(1)
      # And then try to use item_from_request_A in request B with db_session_B:
      # item_from_request_A.name = "new name"
      # db_session_B.add(item_from_request_A) # This will fail.
      # The fix is to re-query:
      # item_from_request_B = db_session_B.query(Item).get(1)
      # item_from_request_B.name = "new name"
      # db_session_B.commit()
      
    • Why it works: Each web request should ideally have its own isolated database session. Objects loaded in one session are not automatically known or managed by sessions in other requests.

After fixing these, the next error you might encounter is a ConcurrentModificationError if multiple requests are trying to update the same database rows without proper locking or transaction management.

Want structured learning?

Take the full Fastapi course →