๐ Migration Guide: Upgrading to Open WebUI 0.9.0
This guide covers breaking changes and required updates for Tools, Functions, Pipes, Filters, and Actions when upgrading to Open WebUI 0.9.0.
๐ง What Has Changed and Why?โ
Open WebUI 0.9.0 ships a large internal refactor: the backend data layer has moved from synchronous to asynchronous from top to bottom. Almost every database-backed method on every model class (Users, Chats, Files, Models, Functions, Tools, Knowledge, Memories, Groups, Folders, Messages, Feedback, โฆ) is now an async def and must be awaited.
At the storage layer, SQLAlchemy is now used in its async mode: sessions are AsyncSession instances, and queries are issued via await db.execute(select(...)) instead of the legacy db.query(...).first() style. A sync engine still exists internally, but it is reserved for startup-only tasks (config loading, Alembic/peewee migrations, health checks) โ all runtime code, including plugins, must use the async engine.
๐ Why did we make this change?
- Concurrency: blocking DB calls on the event loop were the biggest bottleneck under load. Making the data layer async lets FastAPI handle many more concurrent chats without thread-pool saturation.
- Consistency: request handlers were already async; the data layer being sync forced awkward
run_in_threadpoolwrappers throughout the codebase. - Forward-looking: SQLAlchemy 2.0's async API is the supported path going forward.
โ ๏ธ Breaking Changesโ
If your plugin touches the Open WebUI database or models โ or calls any helper that eventually touches them โ you will need to update it. Because almost every utility in open_webui.utils.* and every router function ultimately reads or writes through a model, "async reach" extends well beyond obvious Users./Chats. calls. Specifically:
- All model methods are now coroutines. Any call like
Users.get_user_by_id(...),Chats.get_chat_by_id(...),Files.get_file_by_id(...),Models.get_model_by_id(...),Functions.get_function_by_id(...),Tools.get_tool_by_id(...),Knowledges.get_knowledge_by_id(...)โ previously synchronous โ now return an awaitable. Calling them withoutawaitreturns a coroutine object, not your data. - Anything downstream of a model call is now async too. Utility helpers you may import from
open_webui.utils.*,open_webui.retrieval.*, router helpers, access-control checks, and similar โ if they touch the DB directly or indirectly, they have been promoted toasync def. Previously-sync helpers you were calling withoutawaitalmost certainly needawaitnow. Re-check every import fromopen_webui.*in your plugin. - Database sessions are now
AsyncSession. Useget_async_db_context(or the no-sharing variantget_async_db) instead of the oldget_db_context. The sync helpers (get_db,SessionLocal) still exist but are reserved for startup code โ using them from a request handler or plugin will block the event loop and contend with the async pool. Direct query code needs to be rewritten to the SQLAlchemy 2.0 async style. - Any plugin helper that awaits one of the above must itself be
async def. Async propagates up through your call graph: once one function in the chain becomes a coroutine, every caller does too, all the way up to your plugin's entrypoint.
Plugin entrypoints themselves (the pipe, inlet, outlet, stream, action, and Tool methods) were already asynchronous in 0.5 and later, so the signatures you declare do not change โ only the bodies do.
Open WebUI now requires an async-capable driver matching your database. Standard deployments get this automatically:
- SQLite uses
aiosqlite(syncsqlite://URLs are rewritten tosqlite+aiosqlite://at runtime). - PostgreSQL uses
asyncpg(postgresql://,postgresql+psycopg2://, andpostgres://URLs are rewritten topostgresql+asyncpg://). - SQLCipher (
sqlite+sqlcipher://) has no async driver and is not supported in 0.9.0. If you rely on SQLCipher for database encryption, stay on 0.8.x or migrate to standard SQLite / Postgres before upgrading.
This is primarily an operator concern, but plugins that pin their own DB URL strings need to follow the same rewriting rules.
โ Step-by-Step Migrationโ
๐ 1. Await every model methodโ
The most common change. Anywhere your plugin reads or writes through a model class, add await.
Before (0.8.x):โ
from open_webui.models.users import Users
from open_webui.models.chats import Chats
def resolve_user(user_id: str):
user = Users.get_user_by_id(user_id)
chats = Chats.get_chat_list_by_user_id(user_id)
return user, chatsAfter (0.9.0):โ
from open_webui.models.users import Users
from open_webui.models.chats import Chats
async def resolve_user(user_id: str):
user = await Users.get_user_by_id(user_id)
chats = await Chats.get_chat_list_by_user_id(user_id)
return user, chatsNote that the helper itself became async def. Its callers must now await it in turn โ async propagates upward through your code.
๐๏ธ 2. Replace get_db_context with get_async_db_contextโ
If your plugin opens its own database session (rare, but some Tools do for custom queries), the sync helper is gone. Switch to the async variant.
Before (0.8.x):โ
from open_webui.internal.db import get_db_context
from open_webui.models.users import User
def count_active_users():
with get_db_context() as db:
return db.query(User).filter_by(is_active=True).count()After (0.9.0):โ
from sqlalchemy import select, func
from open_webui.internal.db import get_async_db_context
from open_webui.models.users import User
async def count_active_users():
async with get_async_db_context() as db:
result = await db.execute(
select(func.count()).select_from(User).where(User.is_active == True)
)
return result.scalar_one()Key differences:
withbecomesasync with.db.query(Model)is no longer supported โ useselect(Model)fromsqlalchemyandawait db.execute(...)..first()/.all()/.count()become.scalars().first(),.scalars().all(), and.scalar_one()respectively, applied to theResultreturned byexecute.
If you rely on type hints, the session type is now sqlalchemy.ext.asyncio.AsyncSession, not sqlalchemy.orm.Session.
SessionLocal, get_db, get_session, and the sync save_config / reset_config paths still exist, but they are reserved for Open WebUI's startup and migration code โ do not call them from a plugin, route handler, or anything running inside the event loop. They acquire connections from the sync pool, block the loop while running, and can deadlock against the async pool under concurrent load. Always prefer the async variants (get_async_db, get_async_db_context, get_async_session, async_save_config, async_reset_config) in any code that runs after startup.
๐งฉ 3. Pipes, Filters, and Actionsโ
Plugin entrypoints were already async since 0.5, so their signatures are unchanged. What changes is how you interact with models inside them.
Before (0.8.x) โ a Pipe that looks up the caller:โ
from pydantic import BaseModel
from fastapi import Request
from open_webui.models.users import Users
from open_webui.utils.chat import generate_chat_completion
class Pipe:
async def pipe(
self,
body: dict,
__user__: dict,
__request__: Request,
) -> str:
full_user = Users.get_user_by_id(__user__["id"])
body["model"] = "llama3.2:latest"
return await generate_chat_completion(__request__, body, full_user)After (0.9.0):โ
from pydantic import BaseModel
from fastapi import Request
from open_webui.models.users import Users
from open_webui.utils.chat import generate_chat_completion
class Pipe:
async def pipe(
self,
body: dict,
__user__: dict,
__request__: Request,
) -> str:
full_user = await Users.get_user_by_id(__user__["id"])
body["model"] = "llama3.2:latest"
return await generate_chat_completion(__request__, body, full_user)A one-line diff in the simplest case โ but every model call inside inlet, outlet, stream, and action needs the same treatment.
๐ ๏ธ 4. Toolsโ
Tool methods can be declared async def (and should be, if they touch the DB). For Tools that only call external APIs and never query Open WebUI's database, no change is required.
Before (0.8.x) โ a Tool that reads a file:โ
from open_webui.models.files import Files
class Tools:
def get_file_preview(self, file_id: str, __user__: dict) -> str:
file = Files.get_file_by_id_and_user_id(file_id, __user__["id"])
return file.data.get("content", "") if file else ""After (0.9.0):โ
from open_webui.models.files import Files
class Tools:
async def get_file_preview(self, file_id: str, __user__: dict) -> str:
file = await Files.get_file_by_id_and_user_id(file_id, __user__["id"])
return file.data.get("content", "") if file else ""๐งต 5. FastAPI dependencies and session typesโ
If your plugin exposes its own FastAPI routes (advanced Tools sometimes do this via router imports) and declares a database session dependency, switch both the dependency and the type annotation.
Before (0.8.x):โ
from fastapi import Depends
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session
@router.get("/my-endpoint")
def my_endpoint(db: Session = Depends(get_session)):
...After (0.9.0):โ
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import get_async_session
@router.get("/my-endpoint")
async def my_endpoint(db: AsyncSession = Depends(get_async_session)):
...๐ฃ 6. Event emitters are still called the same wayโ
The internal constructors get_event_emitter / get_event_call were made async in this release, but plugin authors do not see this. You still receive the already-constructed callables as __event_emitter__ and __event_call__ in your function signature, and you still await them exactly as before:
await __event_emitter__({"type": "status", "data": {...}})
response = await __event_call__({"type": "input", "data": {...}})No plugin changes are required here.
๐ 7. Third-party libraries that don't yet support asyncโ
If you depend on a library that only exposes a sync API (for example a legacy HTTP client or a library that does its own blocking I/O), do not block the event loop. Wrap the sync call in a thread:
import anyio
result = await anyio.to_thread.run_sync(legacy_client.fetch, url)This keeps Open WebUI's event loop free while your sync call runs on a worker thread. Prefer an async-native replacement (e.g. httpx.AsyncClient, aiofiles) where one exists.
๐ Recapโ
- Add
awaitto every model method call โ they all return coroutines now. - Do the same for every
open_webui.*helper that sits on top of the DB (most ofutils, retrieval, access control, router helpers). If it used to be sync and touched data, it is async now. - Any function that awaits one of the above must itself be
async defโ async propagates up through your entire call graph. - Replace
get_db_contextwithget_async_db_context(orget_async_dbwhen you don't need session-sharing) and rewrite raw queries in the SQLAlchemy 2.0 async style (select(...)+await db.execute(...)). - Session type is now
AsyncSession, notSession. For FastAPI routes, depend onget_async_sessioninstead ofget_session. - Treat the sync helpers (
SessionLocal,get_db,get_session, syncsave_config/reset_config) as internal startup-only plumbing โ never call them from plugin code or anything that runs inside the event loop. - Plugin entrypoint signatures don't change โ only the code inside them. Event emitters (
__event_emitter__,__event_call__) are used exactly as before. - Don't block the event loop; wrap unavoidable sync calls with
anyio.to_thread.run_sync. - Operators: make sure your database URL targets an async driver (
aiosqlite/asyncpg). SQLCipher-encrypted SQLite is not supported in 0.9.0.
A mechanical sweep (await before every Users./Chats./Files./Models./Functions./Tools./Knowledges. call, then propagate async up through callers) gets most plugins across the line.
๐ฌ Questions or Feedback? If you run into any issues or have suggestions, feel free to open a GitHub issue or ask in the community forums!