Upgrade 0.3 to 0.4¶
0.4 is a breaking release
Unlike earlier 0.4 previews, the shipping 0.4.0 upgrades the entire web stack: Pydantic v1 → v2, FastAPI 0.115 → 0.137, and Starlette 0.45 → 1.3. Pydantic v2 is the headline, FastAPI dropped Pydantic v1 entirely (in 0.126) so the two move together. There is no "do nothing" path this time, plan to read this whole page before you bump.
The good news: Uvicore's own layer absorbs most of the churn. Your Field() definitions,
relations, tables, query builder, configs, seeders and Guard/scopes are unchanged. The
breakage clusters in a handful of predictable spots, listed below worst-first.
See the 0.4 Changelog for the full feature list.
Required Steps¶
These will stop your app from booting or running until addressed.
1. Remove update_forward_refs() from your models¶
In Pydantic v2 update_forward_refs() is an alias for model_rebuild(), which eagerly builds a
model's entire related-model schema graph. Called at the bottom of each model file (mid
import-cascade), it raises PydanticUndefinedAnnotation before the sibling models exist.
Uvicore now rebuilds every registered model once, centrally, after all model modules are
imported (see database/package/bootstrap.py), so the per-file call is both broken and redundant.
Delete it. Keep from __future__ import annotations and the bottom-of-file relation imports,
those are still required so the forward references can resolve.
# acme/wiki/models/post.py
from __future__ import annotations # <-- KEEP (makes relations forward refs)
from uvicore.orm import Model, ModelMetaclass, Field, BelongsTo
@uvicore.model()
class Post(Model['Post'], metaclass=ModelMetaclass):
__tableclass__ = table.Posts
id: Optional[int] = Field('id', primary=True, read_only=True)
creator: Optional[User] = Field(None, relation=BelongsTo('acme.wiki.models.user.User'))
from acme.wiki.models.user import User # isort:skip # <-- KEEP (resolves the forward ref)
# Post.update_forward_refs() <-- DELETE: Uvicore rebuilds models for you now
2. Add explicit defaults to your own Optional Pydantic models¶
Pydantic v2 no longer treats Optional[x] as implicitly defaulting to None, an optional field
with no default is now required and raises ValidationError. This affects the plain Pydantic
models you write yourself (request/response bodies, DTOs).
from pydantic import BaseModel
class SearchQuery(BaseModel):
term: str
limit: Optional[int] # v1: optional | v2: REQUIRED -> ValidationError
limit: Optional[int] = None # v2 fix: add the explicit default
Your ORM models are exempt
Uvicore's ModelMetaclass defaults every ORM field to None for you (models are populated
from partial DB rows), so your Field(...) definitions need no changes. This step is only
about hand-written BaseModel subclasses.
3. Replace on_event startup/shutdown hooks¶
Starlette 1.0 removed on_event/add_event_handler from the application, so
@uvicore.app.http.on_event("startup") no longer exists. Listen to Uvicore's HTTP server events
instead (typically from your provider's boot()):
# Before (0.3)
@uvicore.app.http.on_event('startup')
async def startup():
...
# After (0.4)
from uvicore.http.events.server import Startup, Shutdown
async def on_http_startup(event):
...
Startup.listen(on_http_startup) # or Startup.listen('acme.wiki.events.listeners.on_http_startup')
Shutdown.listen(on_http_shutdown)
4. Port custom validators¶
Pydantic v1 @validator / @root_validator are superseded by @field_validator /
@model_validator (different signatures and return semantics).
# Before
from pydantic import validator
@validator('slug')
def lower(cls, v): return v.lower()
# After
from pydantic import field_validator
@field_validator('slug')
@classmethod
def lower(cls, v): return v.lower()
5. Fix removed Pydantic imports¶
If any of your code imports these, they were moved or deleted in v2:
| Pydantic v1 | Pydantic v2 |
|---|---|
from pydantic.generics import GenericModel |
class X(BaseModel, Generic[T]) (BaseModel is generic) |
from pydantic.typing import ... |
the stdlib typing module |
from pydantic.fields import ModelField |
FieldInfo (different shape) via Model.model_fields |
custom type __get_validators__ / __modify_schema__ |
__get_pydantic_core_schema__ / __get_pydantic_json_schema__ |
HTTP Client Changed From aiohttp to httpx¶
Uvicore's bundled async HTTP Client switched from aiohttp to
httpx in 0.4. aiohttp is no longer a dependency. If any of your code calls
uvicore.ioc.make('aiohttp'), it will fail until updated.
The client is now resolved as http_client or httpx, the request is awaited directly (no
async with), and the response accessors are not awaited:
# Before (0.3 — aiohttp)
http = uvicore.ioc.make('aiohttp')
async with http.get(url, auth=aiohttp.BasicAuth(user, pw)) as r:
if r.status == 200:
data = await r.json()
# After (0.4 — httpx)
http = uvicore.ioc.make('httpx') # or 'http_client'
r = await http.get(url, auth=(user, pw))
if r.status_code == 200:
data = r.json()
The full mapping of every aiohttp idiom to its httpx equivalent:
| aiohttp (0.3) | httpx (0.4) |
|---|---|
uvicore.ioc.make('aiohttp') |
uvicore.ioc.make('httpx') (or 'http_client') |
async with http.get(url) as r: |
r = await http.get(url) |
async with http.post(url, json=p) as r: |
r = await http.post(url, json=p) |
r.status |
r.status_code |
await r.text() |
r.text |
await r.json() |
r.json() |
await r.read() |
r.content |
aiohttp.BasicAuth(user, pw) |
(user, pw) or httpx.BasicAuth(user, pw) |
aiohttp.FormData() + .add_field(...) |
data={...} (form) and/or files=[...] (uploads) |
repeated add_field('to', x) |
data={'to': ['a', 'b']} (list value) |
await session.close() |
await client.aclose() |
The two gotchas that catch everyone:
- No
async withon the request. httpx awaits the request and hands you a fully‑read response.async withis only for streaming. - Response accessors aren't awaited.
r.textis a property andr.json()is a sync method,awaiting either is an error.
See the HTTP Client guide for the complete, httpx‑based API including auth helpers, timeouts, streaming, error handling and building your own client instances.
httpx is also the pytest client
Uvicore already used httpx internally for its in‑process ASGI test client, so 0.4 simply
consolidates onto the one library. If you write framework‑style tests with
httpx.AsyncClient, note the modern signature is
AsyncClient(transport=ASGITransport(app=...)) (the old app= shortcut was removed in
httpx 0.28).
Deprecations¶
These still work but emit PydanticDeprecatedSince20 warnings, migrate when convenient.
- Serialization methods:
.dict()→.model_dump(),.json()→.model_dump_json(),.copy()→.model_copy(),.parse_obj()→.model_validate(),.construct()→.model_construct(). - Model config: the inner
class Config:→model_config = ConfigDict(...). Several keys were renamed:schema_extra→json_schema_extra,orm_mode→from_attributes,allow_population_by_field_name→populate_by_name. - Response classes:
response.ORJSON/response.UJSONare deprecated upstream in FastAPI but still re-exported.
# Before
class DeleteQuery(BaseModel):
where: Optional[dict] = None
class Config:
schema_extra = {"example": {"where": {"id": 1}}}
# After
from pydantic import ConfigDict
class DeleteQuery(BaseModel):
where: Optional[dict] = None
model_config = ConfigDict(json_schema_extra={"example": {"where": {"id": 1}}})
Behavioral Changes To Watch¶
No error, but the output or behavior shifts:
- Stricter validation/coercion. Pydantic v2 is less forgiving about loose type coercion and unexpected types when constructing models from input.
- JSON output differs.
model_dump()handlesNone/nested values differently than v1.dict(), and FastAPI now generates OpenAPI 3.1 (was 3.0). Any test that asserts an exact response body oropenapi.jsonstructure will need updating. Field metadata you set viaField()(read_only,write_only,sortable, …) still appears in the schema (asreadOnly,writeOnly, and thex-traextension block). - Python floor. FastAPI 0.137 requires Python ≥ 3.10. Uvicore already required 3.10, so compliant apps are unaffected.
What Is Not Affected¶
To keep the scope honest, these public surfaces are unchanged in 0.4:
- The uvicore
Field()API —primary,read_only,write_only,description,min_length/max_length,relation=...,callback=, etc. - All relation types (
BelongsTo,HasOne/Many,BelongsToMany, theMorph*family). - Tables, the ORM query builder, seeders, config files, and Guard / auth scopes.
New: Inline Tables¶
You can now define an ORM model's table schema fully inline, without a separate Table class, using __connection__, __tablename__ and a raw __table__ column list.
@uvicore.model()
class Post(Model['Post'], metaclass=ModelMetaclass):
__connection__ = 'wiki'
__tablename__ = 'posts'
__table__ = [
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('title', sa.String(length=100)),
]
id: Optional[int] = Field('id', primary=True, read_only=True)
title: str = Field('title')
Your existing __tableclass__ models are completely unaffected, this is just an additional option.
New: More Query Operators¶
.where(), .or_where(), .filter() and .or_filter() now accept more operators: <>, <=, not in, not like, ilike, !ilike, between, !between, and explicit is / is not. Operators are now case-insensitive and whitespace tolerant.
The most useful addition is ilike for portable case-insensitive matching:
# 'like' is case-sensitive on Postgres but not on SQLite/MySQL.
# 'ilike' is case-insensitive everywhere.
posts = await Post.query().where('title', 'ilike', '%uvicore%').get()
Behavior Improvements to Be Aware Of¶
These are bug fixes rather than breaking changes, but they alter behavior you may have worked around:
- Multiple
*Manyincludes. Eager-loading several many-relations in one query (for example.include('comments', 'tags')) previously returned duplicated/cartesian rows. Results are now correct. If you had added manual de-duplication to compensate, you can remove it. - Auto-increment primary keys. Inserts no longer send an explicit
NULLprimary key. Behavior is unchanged on SQLite, and inserts that previously failed on Postgres/MySQL now succeed. find()by primary key coerces the value to the column's type, so passing a string id works on strict engines like Postgres.HasManydelete()/set()now work (they previously raised).
If You Use Postgres¶
Postgres connections now work whether you set dialect to postgres or postgresql (the postgres alias is normalized automatically). No config change is required, but postgresql is the canonical value.
# Both of these now work identically
'dialect': env('DB_WIKI_DIALECT', 'postgresql'),
Upgrade tips
- Delete
update_forward_refs()from every model, keepfrom __future__ import annotationsand the bottom-of-file relation imports. - Add
= Noneto optional fields in your ownBaseModelclasses (ORM models are handled for you). - Swap
on_eventforStartup.listen()/Shutdown.listen(). - Port
@validator→@field_validator,class Config→model_config, and.dict()/.json()→.model_dump()/.model_dump_json(). - Re-check any test that asserts exact JSON or OpenAPI output, v2 + OpenAPI 3.1 shift the shape.
- Swap
uvicore.ioc.make('aiohttp')formake('httpx'), await requests directly (noasync with), and user.status_code/r.text/r.json()(not awaited).