Mapping Tortoise database errors to HTTP status codes, and generating Pydantic schemas from Tortoise models — including the type mappings that lose information and the fields you must exclude.
Exception Handlers & Pydantic
Section titled “Exception Handlers & Pydantic”Two integration points between sillo.record and the HTTP layer. The
first turns database exceptions into sensible status codes instead of
500s. The second generates request-validation schemas from model
definitions so the shape is not written twice.
Database exception handlers
Section titled “Database exception handlers”Without handlers, a DoesNotExist raised by await User.get(id=999)
propagates to the framework’s catch-all and becomes a 500 — the wrong
answer, since nothing is broken and the client asked for something that
is not there.
from sillo import silloAppfrom sillo.record import register_db_exception_handlers
app = silloApp()register_db_exception_handlers(app)That registers four handlers:
| Tortoise exception | Status | Body |
|---|---|---|
DoesNotExist | 404 | {"error": "Not Found", "detail": "<exception message>"} |
IntegrityError | 409 | {"error": "Conflict", "detail": "<exception message>"} |
ValidationError | 422 | {"error": "Validation Error", "detail": "<exception message>"} |
OperationalError | 503 | {"error": "Service Unavailable", "detail": "Database unavailable"} |
OperationalError is the one that does not include the exception text —
it returns a fixed string, because a connection error message can carry
the host, port, and username of your database server.
The other three do include str(exc), and that is the thing to think
about before shipping.
When to reach for this
Section titled “When to reach for this”Register the handlers in any application where handlers query the
database directly. The alternative — a try/except around every
get() — is more code and gets forgotten.
Do not register them if your handlers already catch these exceptions and
produce domain-specific responses; a handler that catches
DoesNotExist locally will never reach the registered one anyway, so
there is no conflict, only redundancy.
The default messages leak schema details
Section titled “The default messages leak schema details”Registering handlers
Section titled “Registering handlers”add_exception_handler works as a direct call or as a decorator when the
handler argument is omitted:
app.add_exception_handler(DoesNotExist, custom_404)
@app.add_exception_handler(DoesNotExist)async def custom_404(request, response, exc): return response.json( {"error": "Resource not found", "code": "NOT_FOUND"}, status_code=404, )Handlers take (request, response, exc) and return a response. They run
inside the middleware stack, so CORS headers, request logging, and
anything else you have registered still apply to the error response.
What the handlers actually return
Section titled “What the handlers actually return”With setup_record and register_db_exception_handlers wired up, a
missing row and a duplicate insert produce:
GET /missing -> 404 {"error": "Not Found", "detail": "Object \"Thing\" does not exist"}
GET /dupe -> 409 {"error": "Conflict", "detail": "UNIQUE constraint failed: thing.name"}The second response names the table and the column. That is the leak described above, in concrete form.
Matching walks the exception’s MRO
Section titled “Matching walks the exception’s MRO”_lookup_exception_handler iterates type(exc).__mro__ and returns the
first class with a registered handler. Most specific wins, which matters
because Tortoise’s hierarchy is not flat:
DoesNotExist -> NotExistOrMultiple -> OperationalError -> BaseORMExceptionIntegrityError -> OperationalError -> BaseORMExceptionValidationError -> BaseORMExceptionFieldError -> BaseORMExceptionDoesNotExist and IntegrityError are both subclasses of
OperationalError. The four default handlers coexist correctly because
each exception finds its own handler earlier in the MRO than the
OperationalError one.
get_or_none() never raises DoesNotExist — it swallows every exception
and returns None. If a handler seems not to fire, check whether the
call site is using get_or_none and returning a None you then have to
handle yourself.
What is not covered
Section titled “What is not covered”The four handlers cover the common cases, not every database error.
Notably absent: TransactionManagementError, FieldError (a bad lookup
name such as filter(emial="x")), ParamsError (a negative offset), and
ConfigurationError (an unregistered model). Those still surface as
500s, which is arguably correct — each is a bug in your code rather than
a client mistake — but you will want them logged loudly.
from tortoise.exceptions import BaseORMException
@app.add_exception_handler(BaseORMException)async def handle_orm_error(request, response, exc): logger.exception("unhandled ORM error on %s", request.url.path) return response.json({"error": "Internal Server Error"}, status_code=500)Generating Pydantic schemas
Section titled “Generating Pydantic schemas”pydantic_model_from_tortoise inspects a model’s fields_map and builds
a Pydantic model, so a request schema does not have to restate every
column.
from sillo.record import pydantic_model_from_tortoise
UserCreate = pydantic_model_from_tortoise( User, name="UserCreate", exclude=["id", "created_at", "updated_at", "deleted_at"], optional_fields=["bio"],)
@app.post("/users", request_model=UserCreate)async def create_user(request, response): user = await User.create(**request.validated_data.model_dump()) return response.json(user.to_dict(), status_code=201)| Parameter | Type | Effect |
|---|---|---|
model_class | type | The Tortoise model to read |
name | str | Name of the generated class; defaults to <Model>Schema |
exclude | list[str] | Fields to omit |
include | list[str] | If set, only these fields |
optional_fields | list[str] | Fields to mark Optional with a None default |
The exclude list is mandatory in practice
Section titled “The exclude list is mandatory in practice”Relations do not map to anything useful
Section titled “Relations do not map to anything useful”fields_map includes forward and reverse relations, and
_tortoise_to_python_type has no case for either, so both fall through
to the str fallback.
pydantic_model_from_tortoise(Tag, name="TagSchema")# article: Optional[str] <- the ForeignKeyFieldInstance# article_id: Optional[int] <- the underlying column, correctpydantic_model_from_tortoise(Article, name="ArticleSchema")# tags: Optional[str] <- a reverse relation, meaninglessThe article_id column is the one you want; the article relation
accessor is not a value a client can send. Exclude relation accessors and
reverse relations explicitly, and let the _id columns through:
TagCreate = pydantic_model_from_tortoise( Tag, name="TagCreate", exclude=["id", "created_at", "updated_at", "deleted_at", "article"],)Type mappings that lose information
Section titled “Type mappings that lose information”| Tortoise field | Generated type | Consequence |
|---|---|---|
IntField, SmallIntField, BigIntField | int | Fine |
FloatField | float | Fine |
DecimalField | float | Precision loss — money should not round-trip through a binary float |
BooleanField | bool | Fine |
CharField, TextField | str | No max_length constraint carried over |
DatetimeField, DateField | str | No parsing or validation — any string passes |
TimeDeltaField | float | |
JSONField | dict | A JSON array fails validation |
| anything else (UUID, enum, relations, binary) | str | Silently wrong |
Two of these deserve a decision rather than a shrug.
DecimalField → float means a price stored as 19.99 becomes a float
that cannot represent 19.99 exactly. For monetary values, hand-write
the field as Decimal or store integer cents.
DatetimeField → str means the schema accepts "tomorrow" and
"not a date" without complaint, and your handler passes that straight
to the database. Override it.
CharField(max_length=50) generating a bare str means the length
constraint is enforced only by the database, which raises after the round
trip instead of returning a clean 422.
Extending a generated schema
Section titled “Extending a generated schema”The generated class is an ordinary Pydantic model, so subclass it to correct the types that matter and add the constraints that were lost:
from datetime import datetimefrom decimal import Decimal
from pydantic import EmailStr, Field
_Base = pydantic_model_from_tortoise( Product, name="_ProductBase", exclude=["id", "created_at", "updated_at", "deleted_at", "reviews"],)
class ProductCreate(_Base): name: str = Field(min_length=1, max_length=200) price: Decimal = Field(gt=0, max_digits=10, decimal_places=2) available_from: datetimeSubclassed fields replace the generated ones by name, so you only restate what the generator got wrong. This is the pattern to reach for whenever a model has money, dates, emails, URLs, or enums in it — which is most models.
Partial updates
Section titled “Partial updates”For a PATCH endpoint, mark every field optional so a client can send one
key, and combine with exclude_unset=True when applying:
UserUpdate = pydantic_model_from_tortoise( User, name="UserUpdate", exclude=["id", "created_at", "updated_at", "deleted_at"], optional_fields=["name", "email", "bio"],)
@app.patch("/users/{user_id}", request_model=UserUpdate)async def update_user(request, response, user_id: str): user = await User.get_or_none(id=user_id) if user is None: return response.json({"error": "Not found"}, status_code=404) await user.update_from_dict( request.validated_data.model_dump(exclude_unset=True) ) return response.json(user.to_dict())optional_fields has to list every field you want optional — there is no
“all optional” switch. Miss one and a PATCH that omits it fails
validation.
When to hand-write the schema instead
Section titled “When to hand-write the schema instead”Generation earns its keep when a model is wide, flat, and made of plain scalars — an internal admin CRUD table, a settings row, a lookup table.
Hand-write the schema when the model has relations, money, dates, enums, or any field where the API’s shape differs from the table’s. That is most public endpoints. A request schema is part of your API contract; a table is an implementation detail, and coupling them means a column rename becomes a breaking API change.
What not to do
Section titled “What not to do”Do not return raw IntegrityError messages from a public endpoint.
They name tables and columns and enable user enumeration.
Do not register a handler for Exception before the specific ones.
It catches everything first.
Do not generate a schema without exclude. id, created_at, and
updated_at come out required.
Do not let relation accessors into a generated schema. They map to
str.
Do not accept DecimalField as float for money. Override with
Decimal.
Do not trust a generated DatetimeField. It is an unvalidated
string.
Do not couple a public API schema to a table. Hand-write it.
Performance notes
Section titled “Performance notes”register_db_exception_handlers costs nothing at request time — handler
lookup is a dict access, and only on the exception path.
pydantic_model_from_tortoise calls pydantic.create_model, which builds
and compiles a validator. Do it once at import time, never inside a
request handler. A generated schema built per request would recompile the
validator on every call.
Validation itself is pydantic-core, which is Rust and fast. The
expensive part of a request is the database round trip, not the schema.
API reference
Section titled “API reference”| Name | Signature | Notes |
|---|---|---|
register_db_exception_handlers | (app) -> None | Registers all four handlers |
handle_does_not_exist | (request, response, exc) | 404 |
handle_integrity_error | (request, response, exc) | 409; includes str(exc) |
handle_validation_error | (request, response, exc) | 422; includes str(exc) |
handle_operational_error | (request, response, exc) | 503; message is fixed |
pydantic_model_from_tortoise | (model_class, *, name="", exclude=None, include=None, optional_fields=None) -> type[BaseModel] | Call at import time |
Related
Section titled “Related”- Record Overview — setup and connection lifecycle
- Models & Mixins —
update_from_dictand mass-assignment safety - Transactions & Factories — where
IntegrityErrorusually comes from - Pagination — the other place errors need mapping to 400
- Error Handling — sillo’s exception middleware in general
- Request Inputs — how
request_modelvalidation fits the request lifecycle