flask-modelkit v1.0.0
Star
Flask-SQLAlchemy · model mixins

The base model you keep rewriting, done once.

Most Flask projects end up with the same base model: a UUID key, created_at/updated_at, save(), to_dict(), maybe soft delete. This package gives you those pieces as small, tested mixins that you combine per model.

Quick start
pip install flask-modelkit
class Post(UUIDMixin, TimestampMixin, SoftDeleteMixin, CRUDMixin, SerializerMixin, db.Model):
    title = db.Column(db.String(200), nullable=False)

Pick what you need

UUID or integer keys, timestamps, soft delete, CRUD helpers and serialisation are separate mixins. BaseModelMixin bundles the common set.

Soft delete that really hides rows

Deleted rows are filtered out of every ORM query: helpers, Model.query, db.select(), session.get() and relationship loads.

Safe writes from request data

create_or_abort(request.json) and update(request.json) only set mapped columns and never touch id, timestamps or a protected_column(). Missing or duplicate fields get a 400 or 409 that names them.

Safe serialisation

to_dict() never outputs a hidden_column(), limits relationship depth, never loops on back_populates, and can return JSON-ready values. secret_column() is hidden and protected at once.

Efficient queries

Counts and existence checks run in SQL. Pagination has a stable order, so no row appears on two pages.

Clean migrations

Defaults are generated in Python, so Alembic never produces endless "alter column" diffs.

Typed

Ships py.typed. User.get() returns User | None, User.filter_all() returns list[User].

Uses your db

No global SQLAlchemy() is forced on you. Mix into your own db.Model, or use the bundled one.

Installation

pip install flask-modelkit

Requirements:

Python ≥ 3.10 Flask ≥ 3.0 Flask-SQLAlchemy ≥ 3.1.1 SQLAlchemy ≥ 2.0.16
Formerly FlaskSQLAlchemyBaseModel

flask-modelkit 1.0 continues the FlaskSQLAlchemyBaseModel package under a new name. To switch, run pip uninstall FlaskSQLAlchemyBaseModel, install flask-modelkit, and change from flasksqlalchemybasemodel import … to from flask_modelkit import …. The mixins create the same columns, so your tables need no migration. Timestamps are now UTC; 1.2 used server-local time.

Everything is importable from the top-level package:

from flask_modelkit import (
    BaseModelMixin,     # UUID + timestamps + CRUD + to_dict
    UUIDMixin, IntIdMixin, TimestampMixin,
    SoftDeleteMixin, CRUDMixin, SerializerMixin,
    hidden_column,      # never sent to the client by to_dict()
    protected_column,   # never set from client input
    secret_column,      # both: for password hashes, API keys, tokens
    utcnow,             # the UTC clock the timestamps use
    PaginationReturn,   # TypedDict returned by paginate()
    InvalidBodyError, MissingFieldsError, DuplicateValueError,  # create_or_abort() errors
    db, BaseModel,      # optional bundled SQLAlchemy() and abstract base
)

Quick start

A complete, runnable app: one model, create/read/update/list endpoints.

  1. Define a model

    Put the mixin before db.Model. You only declare your own columns; id, created_at and updated_at come from the mixin.

    models.py
    from flask_sqlalchemy import SQLAlchemy
    from flask_modelkit import BaseModelMixin, protected_column, secret_column
    
    db = SQLAlchemy()
    
    
    class User(BaseModelMixin, db.Model):
        name = db.Column(db.String(50), nullable=False)
        email = db.Column(db.String(100), unique=True, nullable=False)
        is_admin = protected_column(db.Boolean, default=False)   # clients can't set it
        password_hash = secret_column(db.String(200))            # never sent, never set
  2. Wire up the app and create the tables

    app.py
    from flask import Flask, jsonify, request
    from werkzeug.security import generate_password_hash
    
    from models import User, db
    
    app = Flask(__name__)
    app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///app.db"
    db.init_app(app)
    
    with app.app_context():
        db.create_all()          # or use Flask-Migrate, see Migrations
  3. Add endpoints

    app.py (continued)
    @app.post("/users")
    def create_user():
        data = request.get_json(silent=True) or {}
        pw = data.get("password")
        # 400 naming missing fields · 409 if the email is taken · unknown keys dropped
        user = User.create_or_abort(
            data,
            required=["password"],
            password_hash=generate_password_hash(pw) if pw else None,
        )
        return jsonify(user.to_dict(json_safe=True)), 201
    
    
    @app.get("/users/<id>")
    def get_user(id):
        return jsonify(User.get_or_404(id).to_dict(json_safe=True))
    
    
    @app.patch("/users/<id>")
    def update_user(id):
        user = User.get_or_404(id)
        user.update(request.get_json(silent=True) or {})   # id, timestamps, is_admin, password_hash ignored
        return jsonify(user.to_dict(json_safe=True))
    
    
    @app.get("/users")
    def list_users():
        page = User.paginate(page=request.args.get("page", 1, type=int), per_page=20)
        page["items"] = [u.to_dict(json_safe=True) for u in page["items"]]
        return jsonify(page)
  4. Call it

    curl -X POST localhost:5000/users \
      -H "Content-Type: application/json" \
      -d '{"name": "Ada", "email": "ada@example.com", "password": "s3cret"}'
    201 Created
    {
      "id": "3f1c9a7e-5b2d-4c8e-9f10-2a6b7d4e1c33",
      "created_at": "2026-11-07T10:15:00.123456",
      "updated_at": "2026-11-07T10:15:00.123456",
      "name": "Ada",
      "email": "ada@example.com",
      "is_admin": false
    }

    No password_hash in the output: it is a secret_column, so it is never sent. Sending "is_admin": true or your own "password_hash" is ignored, because both are protected. See Hidden vs protected.

    Bad requests get a clear JSON error, without any code of yours:

    400 Bad Request · body {"name": "Ada"}
    {"error": "missing_fields", "message": "Missing required field(s): email, password", "fields": ["email", "password"]}
    409 Conflict · same email again
    {"error": "duplicate", "message": "A User with this email already exists.", "fields": ["email"]}

The ordering rule

There is one rule to remember: mixins go before db.Model in the class bases.

class Post(BaseModelMixin, db.Model):   # ✓ correct
class Post(db.Model, BaseModelMixin):   # ✗ wrong

Python runs base-class constructors in method-resolution order, and SQLAlchemy's constructor does not call the next one in line. With the mixins first, UUIDMixin and TimestampMixin run their __init__, fill in id and the timestamps, then hand off to SQLAlchemy. That is why an id exists before you save:

post = Post(title="Hello")
post.id          # '3f2b…'  usable right away, e.g. to build a URL
post.created_at  # datetime(2026, 11, 7, 10, 15, 0, 123456)

The order among the mixins does not matter.

Getting started

Use with AI agents

AI coding assistants know Flask-SQLAlchemy well, but they don't know this package's rules. Without them, an agent will write User.create(**request.json), put db.Model first, or store a password hash in a hidden_column. flask-modelkit ships an AGENT.md that spells those rules out. Add it to your project once, and your agent follows it.

https://anoziechibuike.github.io/FlaskModelKit/AGENT.md

Add it to your agent

Run one of these in your project's root folder:

# Save the rules, then import them from CLAUDE.md
mkdir -p .claude
curl -fsSL https://anoziechibuike.github.io/FlaskModelKit/AGENT.md -o .claude/flask-modelkit.md
echo "@.claude/flask-modelkit.md" >> CLAUDE.md

Commit the file it creates so everyone on your team, and every CI agent, gets the same rules.

Keep it in step with your version

The link above always serves the latest rules. To match the version in your requirements.txt, download from the release tag instead, for example https://raw.githubusercontent.com/AnozieChibuike/FlaskModelKit/v1.0.0/AGENT.md. Run the command again after upgrading.

What it teaches the agent

  • Mixins before db.Model, and which mixins to combine for which model.
  • Request data goes through create_or_abort() and update(), never create(**request.json).
  • Hidden vs protected: secret_column for secrets, protected_column for permissions, ownership and computed values.
  • Protected, required fields are passed as keyword arguments, such as author=current_user.
  • UTC timestamps, soft-delete behaviour, JSON with to_dict(json_safe=True), and SQL counts instead of len(Model.all()).
Mixins

Overview & builder

Every feature is a separate mixin. Choose a primary key and the features you want, and the builder shows the class line, the columns your table gets, and the methods available on the model.

Primary key
Features

Your model

Table columns

Methods & properties

MixinAdds
BaseModelMixinUUIDMixin + TimestampMixin + CRUDMixin + SerializerMixin
UUIDMixinid: UUID4 string primary key, set when the object is constructed
IntIdMixinid: auto-increment integer primary key, set by the database on insert
TimestampMixincreated_at, updated_at in UTC
SoftDeleteMixindeleted_at, soft_delete(), restore(), is_deleted; hides deleted rows
CRUDMixincreate, save, update, delete, get, filters, counts, paginate
SerializerMixinto_dict(), hidden fields
Mixin

BaseModelMixin

The batteries-included combination, and the right default for most models. It is exactly:

class BaseModelMixin(UUIDMixin, TimestampMixin, CRUDMixin, SerializerMixin):
    """UUID key + timestamps + CRUD/query helpers + to_dict()."""
class Article(BaseModelMixin, db.Model):
    title = db.Column(db.String(200), nullable=False)
    body = db.Column(db.Text)

article = Article.create(title="Hello")
article.id, article.created_at      # set automatically
article.update({"body": "…"})       # safe update + commit
article.to_dict(json_safe=True)     # ready for jsonify()
table articleBaseModelMixin
idString(126) PKUUIDMixin
created_atDateTime NOT NULLTimestampMixin
updated_atDateTime NOT NULLTimestampMixin
titleString(200) NOT NULLyour column
bodyTextyour column

Add soft delete by putting SoftDeleteMixin in front: class Article(SoftDeleteMixin, BaseModelMixin, db.Model).

Mixin · primary key

UUIDMixin

A UUID4 string primary key. It is generated in Python when the object is constructed, so you can use it before the first flush: in a URL, a log line, or as a foreign key for a child created in the same transaction.

columnid: Mapped[str] = String(126), primary_key=True

Default: str(uuid.uuid4()). Pass id=... to the constructor to choose it yourself.

class Invite(UUIDMixin, CRUDMixin, db.Model):
    email = db.Column(db.String(120))

invite = Invite(email="ada@example.com")
link = f"https://app.example.com/invites/{invite.id}"   # before saving
invite.save()

legacy = Invite(id="b1946ac9-2a7c-4d3e-9b1f-0c1d2e3f4a5b", email="x@y.z")  # explicit id
Why String(126)?

Strings work identically on SQLite, PostgreSQL and MySQL, and the extra width leaves room for prefixed ids if you ever pass your own. A foreign key to a UUID model is db.Column(db.String(126), db.ForeignKey("table.id")).

Mixin · primary key

IntIdMixin

An auto-increment integer primary key, assigned by the database on insert. Good for lookup tables, internal join tables, or when you are adding the package to an existing integer-keyed schema.

columnid: Mapped[int] = Integer, primary_key=True, autoincrement=True

The value is None until the first flush or commit.

class Tag(IntIdMixin, CRUDMixin, SerializerMixin, db.Model):
    name = db.Column(db.String(30), unique=True)

tag = Tag(name="python")
tag.id          # None
tag.save()
tag.id          # 1

Use one primary-key mixin per model: UUIDMixin or IntIdMixin, never both.

Mixin

TimestampMixin

Adds created_at and updated_at, both in UTC. Both are set to the same instant when the object is constructed.

columncreated_at: Mapped[datetime] = DateTime, nullable=False

Set once, on construction. Protected from update().

columnupdated_at: Mapped[datetime] = DateTime, nullable=False, onupdate=utcnow

Refreshed on every UPDATE of the row, whether through save()/update() or a plain db.session.commit() after setting attributes.

class Note(UUIDMixin, TimestampMixin, CRUDMixin, db.Model):
    text = db.Column(db.Text)

note = Note.create(text="draft")
note.created_at == note.updated_at   # True

note.text = "final"
db.session.commit()                  # plain commit still bumps updated_at
note.updated_at > note.created_at    # True
Naive UTC

Values are stored as naive UTC datetimes, so they round-trip identically on SQLite (which drops tzinfo), PostgreSQL and MySQL. Compare them against utcnow(), not datetime.now().

from datetime import timedelta
from flask_modelkit import utcnow

recent = db.session.scalars(
    Note.select().where(Note.created_at >= utcnow() - timedelta(days=7))
).all()
Mixin

SoftDeleteMixin

Marks rows as deleted instead of removing them. Deleted rows are hidden from every ORM query automatically. Full behaviour is in the soft delete guide.

columndeleted_at: Mapped[datetime | None] = DateTime, nullable=True, index=True

NULL while the row is live; the deletion time (UTC) once soft-deleted. Protected from update().

propertyis_deleted→ bool

True when deleted_at is set.

methodsoft_delete(commit=True)→ None

Sets deleted_at to now. The row stays in the table.

methodrestore(commit=True)→ None

Clears deleted_at, so the row is visible again.

class Post(SoftDeleteMixin, BaseModelMixin, db.Model):
    title = db.Column(db.String(200))

post.soft_delete()
post.is_deleted                    # True
Post.get(post.id)                  # None, hidden
Post.get(post.id, include_deleted=True).restore()
Mixin

CRUDMixin

Write methods on instances and query helpers on the class. All of them use the session of the db your model inherits from and must run inside an application context (a request, a CLI command, or with app.app_context():).

What update() and create_or_abort() may change is controlled by protected fields: mark columns with protected_column() or list names in __protected_fields__. Each method is covered in the guides and the API reference.

Mixin

SerializerMixin

Adds to_dict(): columns plus related objects, with depth control, cycle safety, hidden fields and optional JSON-safe conversion.

user.to_dict()
# {"id": "…", "created_at": datetime(…), "updated_at": datetime(…),
#  "name": "Ada", "posts": [{…}, {…}]}

user.to_dict(json_safe=True)       # datetimes → ISO strings, etc.
user.to_dict(depth=0)              # columns only
user.to_dict(exclude=["email"])    # leave fields out of this response

See Serialisation for every option, Hidden fields for keeping columns out of responses, and Hidden vs protected for secrets.

Convenience

Bundled db & BaseModel

If your app does not have a SQLAlchemy() instance yet, use the package's. BaseModel is an abstract class equivalent to BaseModelMixin, db.Model, bound to that db.

from flask_modelkit import BaseModel, db

class User(BaseModel):
    name = db.Column(db.String(50))

db.init_app(app)
Pick one db

Models from the bundled db and from your own SQLAlchemy() live in separate metadata and sessions. Use one or the other per app. If you already have a db, mix BaseModelMixin into your own db.Model instead.

Mixins

Recipes

Default model

class User(BaseModelMixin, db.Model):
    email = db.Column(db.String(100), unique=True, nullable=False)

A user with permissions and secrets

Permission flags are protected so clients can't grant them to themselves. The password hash is a secret: never sent, never set from input.

class User(BaseModelMixin, db.Model):
    email = db.Column(db.String(100), unique=True, nullable=False)
    is_admin = protected_column(db.Boolean, default=False, nullable=False)
    password_hash = secret_column(db.String(255), nullable=False)

Soft-deletable content

class Post(SoftDeleteMixin, BaseModelMixin, db.Model):
    title = db.Column(db.String(200))

Integer keys, no timestamps

class Country(IntIdMixin, CRUDMixin, SerializerMixin, db.Model):
    code = db.Column(db.String(2), unique=True)

Columns only, no helpers

For when you prefer plain db.session calls but still want consistent keys and timestamps.

class AuditLog(UUIDMixin, TimestampMixin, db.Model):
    action = db.Column(db.String(50))

db.session.add(AuditLog(action="login"))
db.session.commit()

A project-wide base class

Combine once, inherit everywhere. __abstract__ = True stops SQLAlchemy creating a table for it.

class Model(SoftDeleteMixin, BaseModelMixin, db.Model):
    __abstract__ = True

class Invoice(Model):
    amount = db.Column(db.Numeric(10, 2))
Guide · CRUD

Creating & updating

user = User.create(name="Ada", email="ada@example.com")   # construct + save

user.name = "Ada L."
user.save()                                               # add + commit

user.update({"name": "Ada Lovelace"})                     # safe set + save
user.delete()                                             # removes the row

update() is safe for request data

update() only sets attributes that are mapped columns or relationships on the model. It silently ignores:

  • unknown keys ({"nonsense": 1}) and method names ({"save": "x"})
  • protected fields: id, created_at, updated_at, deleted_at, plus any you mark
user.update({
    "name": "Ada",             # set
    "id": "hacked",            # ignored: protected
    "created_at": "1970-01-01",# ignored: protected
    "role": "admin",           # ignored: not a column
})
create() is not filtered

create(**kwargs) passes everything to the constructor and is meant for trusted code. Never call User.create(**request.json); use create_or_abort() for request data.

Guide · CRUD

Create from request data

create_or_abort() is create() for untrusted input. It validates the input, drops unsafe keys, and raises a JSON HTTP error with the exact field names when something is wrong. Most endpoints become one line:

@app.post("/posts")
def create_post():
    post = Post.create_or_abort(request.get_json(silent=True), author=current_user)
    return jsonify(post.to_dict(json_safe=True)), 201
classmethodcreate_or_abort(data, *, fields=None, required=(), commit=True, **values)→ T
data
Untrusted input, usually request.get_json(silent=True). None is treated as {}.
fields
Optional allowlist. Only these keys are read from data.
required
Extra names that must be present in data, even if they aren't columns (for example "password").
commit
As on create(). With False the row is still flushed, so conflicts are reported immediately.
**values
Trusted values such as author=current_user or a password hash. They override data and skip protection.

What happens, in order

  1. Check the body is an object

    A list or string raises InvalidBodyError (400).

  2. Drop unsafe keys

    Keys that aren't mapped attributes, or are protected, are removed. With fields=, anything not listed is removed too.

  3. Check the endpoint itself

    If a required column can never be filled, because it is protected or left out of fields= and you didn't pass it as a keyword argument, a TypeError explains which field and how to fix it. This is a bug in the endpoint, not the request, so it is raised on every call, even for an empty body, and you find it the first time you try the route.

  4. Check required fields

    Every NOT NULL column without a default is required, plus any names in required=. Passing a relationship (author=user) counts for its foreign-key column (author_id). None, "" and whitespace-only strings count as missing. All missing fields are reported together with MissingFieldsError (400).

  5. Create and commit

    If a unique value is already taken, the session is rolled back and DuplicateValueError (409) names the field. Single-column, composite and soft-deleted conflicts are all detected. Other integrity errors are re-raised so real bugs aren't hidden behind a 409.

Derived values like password hashes

The client sends password, but the column is password_hash. Mark the input as required and pass the hash as a trusted value:

data = request.get_json(silent=True) or {}
pw = data.get("password")
user = User.create_or_abort(
    data,
    required=["password"],
    password_hash=generate_password_hash(pw) if pw else None,
)

Error responses

Errors render as JSON without any setup:

ExceptionStatuserrorWhen
InvalidBodyError400invalid_bodyInput is not an object
MissingFieldsError400missing_fieldsRequired fields missing or blank
DuplicateValueError409duplicateA unique value is taken
{"error": "missing_fields", "message": "Missing required field(s): name, email", "fields": ["name", "email"]}

They subclass Werkzeug's BadRequest and Conflict, so an error handler you already have still receives them, with the field names on e.fields:

from flask_modelkit import MissingFieldsError

@app.errorhandler(MissingFieldsError)
def missing(e):
    return {"ok": False, "missing": e.fields}, 400
What it doesn't check

Types and formats, such as an integer for age or a valid email address. Use a schema library like marshmallow or pydantic for that and pass its output in. Also note that a 409 rolls back the whole session, including other uncommitted changes.

Guide · CRUD

Protected fields

A protected field can never be set from client input. When you pass a request body to update() or create_or_abort(), protected keys are dropped silently and everything else is applied:

user.update({"name": "Ada L.", "is_admin": True, "id": "hacked"})
user.name       # "Ada L."   set
user.is_admin   # False      ignored: protected
user.id         # unchanged  ignored: protected by default

Without protection, any client could send PATCH /me {"is_admin": true} and make itself an admin. This is called mass assignment, and it is the reason protection exists.

Protected by defaultWhy
idChanging a primary key would orphan related rows
created_at, updated_atTimestamps must come from the server's clock
deleted_atClients can't undelete through a PATCH; use restore()

Mark a column protected

Mark the field where you declare it, in either of two equivalent ways:

from flask_modelkit import BaseModelMixin, protected_column

class Post(BaseModelMixin, db.Model):
    title = db.Column(db.String(200), nullable=False)

    # 1. Shorthand: same arguments as db.Column, ForeignKey included
    author_id = protected_column(db.String(126), db.ForeignKey("user.id"), nullable=False)

    # 2. The info flag: works on db.Column, mapped_column and db.relationship
    status = db.Column(db.String(20), default="draft", info={"protected": True})
    author = db.relationship("User", info={"protected": True})

protected_column(...) is shorthand for info={"protected": True}. info is SQLAlchemy's own metadata dictionary, so any other keys you keep there are preserved.

Protecting by name, and what the | means

The third way is __protected_fields__, a class-level frozenset of names. It is combined with the column flags. Use it for fields you don't declare yourself, such as a column that comes from another package's mixin:

class Invoice(TenantMixin, BaseModelMixin, db.Model):    # TenantMixin adds tenant_id
    __protected_fields__ = BaseModelMixin.__protected_fields__ | {"tenant_id"}

The | is Python's set union operator: a | b is a new set containing everything in either set.

{"id", "created_at"} | {"email"}    # {"id", "created_at", "email"}

So BaseModelMixin.__protected_fields__ | {"email"} means "the inherited protected fields, plus email". Always start from the inherited set:

__protected_fields__ = BaseModelMixin.__protected_fields__ | {"email"}   # ✓ id, timestamps, email
__protected_fields__ = frozenset({"email"})                             # ✗ only email: id is writable again!
Prefer the column helpers

protected_column() and info={"protected": True} can't accidentally drop the defaults, and the protection is visible right next to the column.

Check the result

Model.protected_fields() returns the final combined set, from every source:

Post.protected_fields()
# frozenset({"id", "created_at", "updated_at", "deleted_at", "author_id", "status", "author"})

Your own code can still set them

Protection only filters input passed to update() and create_or_abort(). Trusted code always works:

user.is_admin = True                          # plain assignment
user.save()
User.create(name="Ada", is_admin=True)        # create() is for trusted values
Post.create_or_abort(data, author=current_user)   # keyword arguments are trusted

Common cases

CaseExample fieldsDo this
Permissionsis_admin, role, email_verifiedprotected_column()
Ownershipauthor_id and authorProtect the foreign key and the relationship. Set the owner as a trusted value.
Server-computed valuestotal, balance, slugprotected_column(), then compute them in your code
Child collectionscomments, postsinfo={"protected": True} on the relationship
Secretspassword_hash, api_keysecret_column(): protected and hidden (why both)
Set once, read-only afterwardsemail, usernameDon't protect. Pass an allowlist to profile updates.

Ownership: protect both sides

A foreign key and its relationship are two separate mapped attributes. Protecting only author_id still lets a client send "author", and protecting only author still lets it send "author_id".

class Post(BaseModelMixin, db.Model):
    author_id = protected_column(db.String(126), db.ForeignKey("user.id"), nullable=False)
    author = db.relationship("User", back_populates="posts", info={"protected": True})

@app.post("/posts")
def create_post():
    # Whatever author_id the client sends is dropped; the owner comes from the session.
    post = Post.create_or_abort(request.get_json(silent=True), author=current_user)
    return jsonify(post.to_dict(json_safe=True)), 201

Server-computed values

class Order(BaseModelMixin, db.Model):
    total = protected_column(db.Numeric(10, 2), default=Decimal("0"), nullable=False)

order.update(request.json)                     # "total": "0.01" is ignored
order.total = sum(l.unit_price * l.quantity for l in order.lines)
order.save()

Child collections

Relationships are mapped attributes, so {"comments": []} in a PATCH body would replace a post's comments. Protect collections that clients manage through their own endpoints:

comments = db.relationship("Comment", back_populates="post", info={"protected": True})

Set once, read-only afterwards

Some fields must be accepted at signup but never changed by a profile edit, such as an email that is only changed through a verification flow. Protection can't express "writable on create only", because it applies to both methods. So leave the field unprotected, and give profile updates an allowlist:

class User(BaseModelMixin, db.Model):
    email = db.Column(db.String(120), unique=True, nullable=False)   # not protected

User.create_or_abort(data, ...)        # signup: email accepted, 400 if missing, 409 if taken

PROFILE_FIELDS = {"name", "bio"}

@app.patch("/me")
def edit_profile():
    data = request.get_json(silent=True) or {}
    current_user.update({k: v for k, v in data.items() if k in PROFILE_FIELDS})
    return jsonify(current_user.to_dict(json_safe=True))
Protected + required = pass it yourself

Input can never fill a protected column. If that column is also NOT NULL without a default, you must pass it as a trusted keyword argument to create_or_abort(). If you forget, create_or_abort() raises a TypeError on the first call that says exactly that:

TypeError: User.create_or_abort(): required field(s) email can never be filled: 'email' is
protected, so request data can't set it. Pass them as keyword arguments, e.g.
User.create_or_abort(data, email=...), or, if clients should send them, stop protecting
them / add them to fields=.

This is the most common protection mistake: protecting email, then expecting it from the signup body. If clients should send it, don't protect it; see "Set once, read-only afterwards".

Guide · CRUD

Transactions

create, create_or_abort, save, update, delete, soft_delete and restore all commit by default. Pass commit=False to stage several changes and commit them together:

order = Order.create(user=user, commit=False)
for item in cart:
    OrderLine.create(order=order, product=item, commit=False)
db.session.commit()          # all or nothing

create_or_abort(..., commit=False) still flushes, so a duplicate value is reported straight away instead of at your final commit. The rollback that comes with a 409 discards every uncommitted change in the session, not only the new row.

If a commit from create, save or update fails (a unique constraint, a foreign key), the session is left in a failed state. Roll it back before using it again:

from sqlalchemy.exc import IntegrityError

try:
    user.update({"email": new_email})
except IntegrityError:
    db.session.rollback()
    abort(409)

create_or_abort() does this rollback for you.

Guide · Queries

Reading & filtering

MethodReturnsSQL
Model.get(id)object or Noneby primary key
Model.get_or_404(id)object, or HTTP 404by primary key
Model.all()list[Model]SELECT
Model.filter_one(**f)first match or NoneLIMIT 1
Model.filter_all(**f)list[Model]WHERE …
Model.exists(**f)boolEXISTS
Model.count_all()intCOUNT
Model.filter_and_count_all(**f)intCOUNT … WHERE
Model.paginate(page, per_page)PaginationReturnLIMIT/OFFSET + COUNT
Model.select()Select to build onnone until run

Filters are exact-match column=value pairs, combined with AND:

User.filter_all(country="NG", is_active=True)
User.filter_one(email="ada@example.com")
User.exists(email="ada@example.com")          # no rows loaded
User.filter_and_count_all(country="NG")      # no rows loaded

User.get(None), User.get("")                  # None, no query

Counts and existence checks run in the database, so they never load rows into Python.

Guide · Queries

Pagination

User.paginate(page=2, per_page=20)
# {"items": [<User>, ...], "total": 135, "pages": 7, "page": 2, "per_page": 20}
  • Ordered by created_at, then primary key, so a row never appears on two pages. Models without timestamps are ordered by primary key.
  • An out-of-range page returns an empty items list, not an error.
  • The return value is a plain dict, typed as PaginationReturn, so you can replace items before returning it:
@app.get("/users")
def list_users():
    page = User.paginate(page=request.args.get("page", 1, type=int), per_page=20)
    page["items"] = [u.to_dict(json_safe=True) for u in page["items"]]
    return jsonify(page)

paginate() does not take filters. To paginate a filtered query, build it with select() and use Flask-SQLAlchemy's db.paginate, as shown next.

Guide · Queries

Custom queries

For ranges, joins, OR, LIKE and custom ordering, start from Model.select() and use SQLAlchemy directly. Soft-deleted rows are still hidden.

from sqlalchemy import or_

stmt = (
    User.select()
    .where(User.created_at >= since)
    .where(or_(User.name.ilike("%ada%"), User.email.ilike("%ada%")))
    .order_by(User.name)
)
users = db.session.scalars(stmt).all()
page = db.paginate(stmt, page=1, per_page=20)    # Flask-SQLAlchemy pagination object

Model.select(include_deleted=True) returns the same statement with soft-deleted rows included.

Guide · Serialisation

Serialisation

methodto_dict(depth=1, exclude=(), json_safe=False)→ dict[str, Any]
depth
Relationship levels to follow. 0 = columns only, 1 (default) = direct relationships, None = every level.
exclude
Field names to leave out of this object (not nested ones).
json_safe
Convert values so the result can go straight into jsonify().

json_safe=True converts column values like this:

Python valueJSON valueExample
datetime, date, timeISO 8601 string"2026-11-07T10:15:00"
Decimalstring (no precision lost)"9.50"
UUIDstring"3f1c…"
Enumits .value"active"
bytesUTF-8 string"…"
return jsonify(user.to_dict(json_safe=True))
Guide · Serialisation

Hidden fields

A hidden field is never sent to the client by to_dict(): not at any depth, and not when the object appears inside another object's relationships. The value is still stored and readable on the object; it is only left out of the output.

Hidden is not enough for secrets

Hidden only stops data going out. A client can still set a hidden column through update() or create_or_abort(). For password hashes, API keys and tokens, use secret_column(), which is hidden and protected. See Hidden vs protected.

Mark a column hidden

hidden_column(...) takes the same arguments as db.Column. It is shorthand for info={"hidden": True}, which also works on db.Column, mapped_column and db.relationship.

from flask_modelkit import BaseModelMixin, hidden_column

class Integration(BaseModelMixin, db.Model):
    name = db.Column(db.String(50))
    webhook_secret = hidden_column(db.String(64))                        # shorthand
    last_error = db.Column(db.Text, info={"hidden": True})               # same thing
    deliveries = db.relationship("Delivery", info={"hidden": True})      # relationships too

hook.webhook_secret                 # "whsec_…"  still readable in your code
"webhook_secret" in hook.to_dict()  # False

Or hide by name

__hidden_fields__ is for hiding a field you don't declare yourself, such as one inherited from a mixin. It is combined with the column flags.

class User(SoftDeleteMixin, BaseModelMixin, db.Model):
    __hidden_fields__ = frozenset({"deleted_at"})   # always null for live rows: no need to send it

Common cases

CaseExampleDo this
Write-only input: the client sends it once and must never see it againA webhook secret or third-party token the user pastes inhidden_column()
Large or expensive relationships kept out of every payloaddeliveries, audit_loginfo={"hidden": True} on the relationship. Add "protected": True if clients mustn't set it either.
Inherited columns you don't want to exposedeleted_at, updated_at__hidden_fields__
Server-generated secrets and server-only datapassword_hash, api_key, fraud_scoresecret_column(), not hidden_column()
Leaving a field out of one response onlyNo email on a public profileto_dict(exclude=["email"])
Hidden vs exclude

Hidden is a rule for the model: the field is never sent, from any endpoint. exclude= is a choice for one response.

Guide · Security

Hidden vs protected

These are two separate controls on what crosses your API boundary. Hidden stops a field going out to the client. Protected stops a field coming in from the client. A field can be either, both or neither.

HiddenProtected
Stops data going…out, to the clientin, from the client
Enforced byto_dict()update(), create_or_abort()
Column helperhidden_column(...)protected_column(...)
Info flaginfo={"hidden": True}info={"protected": True}
By name__hidden_fields____protected_fields__
On by default fornothingid, timestamps, deleted_at
Your own codecan still read itcan still set it

Fields that need both use secret_column(...), which sets both flags.

Which one do I need?

Answer two questions about a field, or pick an example, to see how to declare it and what each method does with it.

Can clients see it in responses?
Can clients set it in requests?
Or try an example

Declare it

What happens

Typical fields

Cases at a glance

FieldHiddenProtectedDeclare with
name, bio, titledb.Column(...)
is_admin, role, author_id, total✓protected_column(...)
A webhook secret the user pastes in✓hidden_column(...)
password_hash, api_key, reset_token, fraud_score✓✓secret_column(...)

The mistake to avoid

password_hash = hidden_column(db.String(255))     # ✗ hidden, but NOT protected

user.update(request.json)    # body: {"password_hash": "<the attacker's own hash>"}

The hash never shows up in responses, but the client just replaced it. Anyone who can set the hash can then log in with a password of their choosing. Declare secrets with secret_column:

password_hash = secret_column(db.String(255), nullable=False)   # ✓ never sent, never set

# Set it from your own code, from the password the client sent:
User.create_or_abort(data, required=["password"],
                     password_hash=generate_password_hash(data["password"]) if data.get("password") else None)

The reverse also matters: protected_column stops is_admin being set, but it still appears in responses. Usually that is what you want, since the UI may need to show it.

Guide · Serialisation

Depth & relationships

Two-way relationships (back_populates or backref) are safe at any depth. An object already serialised higher up the tree is skipped, so there is no infinite loop:

author.to_dict(depth=None)
# {"name": "A", "books": [{"title": "B", "chapters": [{"title": "C"}]}]}
#                          ^ no "author" key inside the book
author.to_dict(depth=0)   # columns only
author.to_dict()          # + books, but each book is columns only
author.to_dict(depth=2)   # + books + each book's chapters
Watch query counts on lists

Each relationship level can mean another query per row. For list endpoints, use depth=0, or load relationships eagerly:

from sqlalchemy.orm import selectinload

stmt = Author.select().options(selectinload(Author.books))
authors = db.session.scalars(stmt).all()
data = [a.to_dict() for a in authors]   # no extra queries for books

On models with SoftDeleteMixin, soft-deleted related objects are left out automatically.

Guide

Soft delete

Soft-deleted rows can be restored, audited, or kept to satisfy foreign keys. Most implementations only filter their own helper methods, so a single raw query leaks deleted data. This mixin hooks into the SQLAlchemy session instead, so deleted rows are left out of every ORM query on the model.

Post.all()                               # hidden
Post.get(id), Post.filter_one(...)       # hidden
Post.count_all(), Post.exists(...)       # hidden
Post.query.filter_by(...).all()          # hidden
db.session.scalars(db.select(Post))      # hidden
db.session.get(Post, id)                 # hidden
author.posts                             # hidden in relationship loads too

Including deleted rows

Post.get(id, include_deleted=True)

stmt = Post.select(include_deleted=True)                       # same as:
stmt = db.select(Post).execution_options(include_deleted=True)

trash = db.session.scalars(
    Post.select(include_deleted=True).where(Post.deleted_at.is_not(None))
).all()

Restoring

post = Post.get(id, include_deleted=True)
post.restore()

Hard delete

delete() still removes the row, even on soft-delete models. Use it for GDPR erasure or purging old trash.

post.delete()      # the row is gone

Limits

  • update() can't undo a deletion. deleted_at is protected.
  • Unique constraints still see deleted rows. A soft-deleted user's email blocks a new signup with the same email, and create_or_abort() reports it as a 409 on email. To let the email be reused, use a partial unique index (WHERE deleted_at IS NULL) on PostgreSQL.
  • Core SQL is not filtered. Statements on Post.__table__ and raw text() SQL see all rows.
  • Bulk statements are not filtered. For update(Post) / delete(Post), add .where(Post.deleted_at.is_(None)) yourself.
# PostgreSQL: unique among live rows only
class User(SoftDeleteMixin, BaseModelMixin, db.Model):
    email = db.Column(db.String(120), nullable=False)
    __table_args__ = (
        db.Index("uq_user_email_live", "email", unique=True,
                 postgresql_where=db.text("deleted_at IS NULL")),
    )
Guide

Migrations

The mixins are ordinary SQLAlchemy columns, so Flask-Migrate (Alembic) picks them up like any other column.

  1. Install and register

    # pip install Flask-Migrate
    from flask_migrate import Migrate
    migrate = Migrate(app, db)
  2. Create the migrations folder (once)

    flask db init
  3. Generate and apply

    flask db migrate -m "add posts"
    flask db upgrade
No spurious migrations

Every default (UUIDs, timestamps) is generated in Python, not by the database, so there are no server defaults for Alembic to compare. Once your tables match your models, flask db migrate reports no changes. The test suite checks this with Alembic's autogenerate comparison.

Adding a mixin to an existing table

SoftDeleteMixin: no edits needed

deleted_at is nullable, so existing rows become "not deleted". The generated migration works as is:

op.add_column("post", sa.Column("deleted_at", sa.DateTime(), nullable=True))
op.create_index("ix_post_deleted_at", "post", ["deleted_at"])

TimestampMixin: backfill existing rows

The columns are NOT NULL. Add them with a temporary server default so existing rows get a value, then drop the default so Python stays in charge:

def upgrade():
    with op.batch_alter_table("post") as batch:   # batch mode also works on SQLite
        batch.add_column(sa.Column("created_at", sa.DateTime(), nullable=False,
                                   server_default=sa.func.current_timestamp()))
        batch.add_column(sa.Column("updated_at", sa.DateTime(), nullable=False,
                                   server_default=sa.func.current_timestamp()))
    with op.batch_alter_table("post") as batch:
        batch.alter_column("created_at", server_default=None)
        batch.alter_column("updated_at", server_default=None)

UUIDMixin on a table with integer ids

Changing a primary key type changes every foreign key that points at it, so don't do it in one autogenerated migration. Keep IntIdMixin for that table, or migrate in steps: add a UUID column and backfill it, update the foreign keys, then swap the primary key.

Timezones

Timestamps are naive UTC. Treat them as UTC in SQL reports:

-- PostgreSQL
SELECT created_at AT TIME ZONE 'UTC' AT TIME ZONE 'Africa/Lagos' FROM post;
Project example

Blog API

A small but complete blog backend: users, posts and comments, with password hashing, protected ownership fields, a trash can, restore, filtered pagination and nested JSON. It uses almost every feature in the package.

from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()
migrate = Migrate()

Run it

pip install flask-modelkit Flask-Migrate
export FLASK_APP="app:create_app"
flask db init && flask db migrate -m "initial" && flask db upgrade
flask run

What GET /posts/<id> returns

{
  "id": "9c0e…", "created_at": "2026-11-07T09:02:11.401233",
  "updated_at": "2026-11-07T09:40:52.118904", "deleted_at": null,
  "title": "Shipping v2", "body": "…", "status": "published",
  "author_id": "3f1c…",
  "author": {
    "id": "3f1c…", "name": "Ada", "email": "ada@example.com", "is_admin": false,
    "created_at": "…", "updated_at": "…", "deleted_at": null
  },
  "comments": [
    {"id": "a71d…", "body": "Congrats!", "post_id": "9c0e…", "…": "…"}
  ]
}

Which feature does what

BehaviourProvided by
No password_hash anywhere in JSON, even nested under authorsecret_column() (hidden)
Signup can't create an admin or set its own password hashprotected_column(), secret_column()
PATCH can't move a post to another author, swap its comments, or forge timestampsprotected_column(), info={"protected": True}
Email can be set at signup but not through a profile editAllowlist passed to update()
Signup answers 400 with the missing fields, or 409 for a taken emailcreate_or_abort()
Deleting a post moves it to /trash; its URL then 404sSoftDeleteMixin + get_or_404()
Deleted comments disappear from post.commentsSoft-delete filter on relationship loads
"status": "published" instead of an enum objectto_dict(json_safe=True)
Post doesn't list itself again inside comments[].postCycle-safe to_dict()
Clean flask db migrate outputPython-side defaults
Project example

Shop checkout

An order and its lines must be saved together or not at all. Stage every write with commit=False, then commit once. UUIDs exist before the flush, so you can return the order id or build a payment reference immediately.

checkout.py
from decimal import Decimal

from flask import abort, jsonify, request
from flask_modelkit import BaseModelMixin, protected_column

from extensions import db


class Product(BaseModelMixin, db.Model):
    name = db.Column(db.String(100), nullable=False)
    price = db.Column(db.Numeric(10, 2), nullable=False)
    stock = db.Column(db.Integer, default=0, nullable=False)


class Order(BaseModelMixin, db.Model):
    # Computed and owned by the server: clients can never set these.
    user_id = protected_column(db.String(126), nullable=False)
    total = protected_column(db.Numeric(10, 2), default=Decimal("0"), nullable=False)
    reference = db.Column(db.String(40), unique=True)
    lines = db.relationship("OrderLine", back_populates="order")


class OrderLine(BaseModelMixin, db.Model):
    order_id = db.Column(db.String(126), db.ForeignKey("order.id"), nullable=False)
    product_id = db.Column(db.String(126), db.ForeignKey("product.id"), nullable=False)
    quantity = db.Column(db.Integer, nullable=False)
    unit_price = db.Column(db.Numeric(10, 2), nullable=False)
    order = db.relationship("Order", back_populates="lines")


# `app` and `current_user` come from your project (e.g. Flask-Login)
@app.post("/checkout")
def checkout():
    cart = request.get_json()["items"]        # [{"product_id": "…", "quantity": 2}]
    order = Order(user_id=current_user.id)
    order.reference = f"ORD-{order.id[:8].upper()}"   # id exists before saving
    total = Decimal("0")

    for item in cart:
        product = Product.get(item["product_id"])
        if product is None or product.stock < item["quantity"]:
            db.session.rollback()
            abort(409, description="item unavailable")
        product.stock -= item["quantity"]
        OrderLine.create(order=order, product_id=product.id, quantity=item["quantity"],
                         unit_price=product.price, commit=False)
        total += product.price * item["quantity"]

    order.total = total
    order.save()                       # one commit: order, lines and stock together
    return jsonify(order.to_dict(json_safe=True)), 201
    # "total": "59.98"  (Decimal → string, no float rounding)
Project example

Lookup tables

Small reference tables don't need UUIDs or timestamps. Combine IntIdMixin with just the helpers you use, and seed them from a CLI command.

countries.py
import click
from flask_modelkit import CRUDMixin, IntIdMixin, SerializerMixin

from extensions import db


class Country(IntIdMixin, CRUDMixin, SerializerMixin, db.Model):
    code = db.Column(db.String(2), unique=True, nullable=False)
    name = db.Column(db.String(80), nullable=False)


SEED = [("NG", "Nigeria"), ("GH", "Ghana"), ("KE", "Kenya")]


@app.cli.command("seed-countries")
def seed_countries():
    for code, name in SEED:
        if not Country.exists(code=code):
            Country.create(code=code, name=name, commit=False)
    db.session.commit()
    click.echo(f"{Country.count_all()} countries")


@app.get("/countries")
def countries():
    return jsonify([c.to_dict() for c in Country.all()])
    # [{"id": 1, "code": "NG", "name": "Nigeria"}, …]
Reference

API reference

Every public name in flask_modelkit. T means "your model class".

CRUDMixin · instance methods

methodsave(commit=True)→ None

Adds the object to the session and commits. With commit=False, only adds it; you commit later.

methodupdate(updates: dict, commit=True)→ None

Sets each key that is a mapped column or relationship and not protected, then calls save(). Other keys are ignored silently.

methoddelete(commit=True)→ None

Removes the row from the database. This is a hard delete, even on SoftDeleteMixin models.

methodclose()→ None

Removes the current scoped session (db.session.remove()). Flask-SQLAlchemy already does this at the end of each request; use it in long-running scripts or workers.

CRUDMixin · class methods

classmethodcreate(commit=True, **kwargs)→ T

Constructs cls(**kwargs), saves it, and returns it. Protection doesn't apply: it is for trusted code, so it can set protected fields.

classmethodcreate_or_abort(data, *, fields=None, required=(), commit=True, **values)→ T

Creates from untrusted input. Drops unknown and protected keys, raises MissingFieldsError (400) or DuplicateValueError (409) with the field names. Raises TypeError if a required column is protected or outside fields= and wasn't passed. See Create from request data.

classmethodget(id, include_deleted=False)→ T | None

Object by primary key. Returns None for a missing row, a soft-deleted row (unless include_deleted=True), or an id of None/"" without querying.

classmethodget_or_404(id)→ T

Like get(), but aborts the request with 404 and the description "<Model> not found".

classmethodall()→ list[T]

Every live row. Prefer paginate() for tables that grow.

classmethodfilter_one(**filters)→ T | None

First row matching all column=value pairs (LIMIT 1).

classmethodfilter_all(**filters)→ list[T]

All rows matching all column=value pairs.

classmethodcount_all()→ int

Number of live rows, computed with SQL COUNT.

classmethodfilter_and_count_all(**filters)→ int

Number of rows matching the filters, with SQL COUNT.

classmethodexists(**filters)→ bool

Whether any row matches, with SQL EXISTS.

classmethodpaginate(page=1, per_page=10)→ PaginationReturn

One page, ordered by created_at (when present) then primary key. Out-of-range pages return empty items.

classmethodselect(include_deleted=False)→ Select

A select(Model) statement to build custom queries on. include_deleted=True sets the execution option that shows soft-deleted rows.

class attribute__protected_fields__: frozenset[str]

Names that update() and create_or_abort() never take from input. Default {"id", "created_at", "updated_at", "deleted_at"}. Extend with |; it is combined with columns marked info={"protected": True}.

classmethodprotected_fields()→ frozenset[str]

The final set of protected names: __protected_fields__ plus every column and relationship marked info={"protected": True} (including protected_column() and secret_column()). Use it to check a model.

SerializerMixin

methodto_dict(depth=1, exclude=(), json_safe=False)→ dict[str, Any]

Columns and relationships as a dict. See Serialisation.

class attribute__hidden_fields__: frozenset[str]

Names never serialised, combined with columns and relationships marked info={"hidden": True}. Default: empty.

SoftDeleteMixin

methodsoft_delete(commit=True)→ None

Sets deleted_at = utcnow() and saves.

methodrestore(commit=True)→ None

Sets deleted_at = None and saves.

propertyis_deleted→ bool

deleted_at is not None.

execution optioninclude_deleted=True

Pass via .execution_options(include_deleted=True) on any ORM statement to include soft-deleted rows.

Functions & types

functionhidden_column(*args, **kwargs)→ mapped_column

A column to_dict() never outputs. Same arguments as db.Column/mapped_column. Adds "hidden": True to the column's info dict, keeping any other info keys you pass. Doesn't stop input from setting it.

functionprotected_column(*args, **kwargs)→ mapped_column

A column update() and create_or_abort() never set from input. Same arguments as db.Column, including db.ForeignKey(...). Adds "protected": True to info. Still appears in to_dict().

functionsecret_column(*args, **kwargs)→ mapped_column

Hidden and protected: never sent, never set from input. Adds "hidden": True and "protected": True to info. Use for password hashes, API keys and tokens.

functionutcnow()→ datetime

Current UTC time as a naive datetime. The clock every timestamp in the package uses.

TypedDictPaginationReturn
items
list of model objects on this page
total
int, rows across all pages
pages
int, number of pages
page
int, the current page (1-based)
per_page
int, page size
exceptionsInvalidBodyError · MissingFieldsError · DuplicateValueError

Raised by create_or_abort(). Subclasses of Werkzeug BadRequest (400) and Conflict (409) that render a JSON body {"error", "message", "fields"}. e.fields holds the field names.

objectdb: SQLAlchemy

The bundled Flask-SQLAlchemy instance. Call db.init_app(app).

classBaseModel

Abstract BaseModelMixin model bound to the bundled db.

Reference

Column reference

ColumnMixinTypeDefaultNotes
idUUIDMixinString(126) PKuuid4() on constructPass id= to override
idIntIdMixinInteger PKdatabase autoincrementNone until flush
created_atTimestampMixinDateTime NOT NULLutcnow() on constructNaive UTC
updated_atTimestampMixinDateTime NOT NULLutcnow(), refreshed on UPDATENaive UTC
deleted_atSoftDeleteMixinDateTime NULL, indexedNULLSet by soft_delete()

All defaults are Python-side; no server_default is set, which keeps Alembic diffs clean. Every column in this table is protected: input passed to update() or create_or_abort() can't set it.

Project

Community & stats

flask-modelkit is free and open source. If it saves you from writing another base model, star it on GitHub. Stars are how other Flask developers find it.

Live numbers

Stars, forks and issues come from the GitHub API. Downloads come from PyPI via pypistats (weekly, monthly) and pepy (all time), and include CI and mirror traffic, so read them as a trend rather than a user count. The numbers refresh at most once an hour.

Star history

Chart of flask-modelkit GitHub stars over time

Show that you use it

Add a badge to your project's README:

[![built with flask-modelkit](https://img.shields.io/badge/built%20with-flask--modelkit-A84E08)](https://anoziechibuike.github.io/FlaskModelKit/)
Reference

Gotchas & FAQ

"TypeError: X must also inherit from a Flask-SQLAlchemy db.Model"

You used CRUDMixin or SoftDeleteMixin on a class that isn't a Flask-SQLAlchemy model. Add db.Model as the last base.

My id is None before saving

Either you are on IntIdMixin (expected), or db.Model comes before the mixins. See the ordering rule.

"Working outside of application context"

All helpers use db.session. In scripts and tests, wrap calls in with app.app_context():.

Can I write db.Column(..., hidden=True) or protected=True?

No. SQLAlchemy rejects unknown keyword arguments on Column. Use hidden_column(...), protected_column(...) or secret_column(...), or put the flags in info: info={"hidden": True, "protected": True}.

Hidden or protected: which do I need?

Hidden stops data going out; protected stops it coming in. Secrets need both, so use secret_column(). The field picker answers it for any field.

Why are my timestamps not timezone-aware?

By design: naive UTC behaves identically on every database. Attach the zone when displaying: dt.replace(tzinfo=timezone.utc).

A soft-deleted user's email blocks new signups

Unique constraints see every row. Use a partial index, as in Soft delete → Limits.

Why not create(abort=True)?

create(**kwargs) passes every keyword to your model, so a flag would clash with a column of the same name. create() is also used in scripts and CLI commands where HTTP errors make no sense. create_or_abort() is a separate method for request data, in the style of get_or_404().

create_or_abort() raises "required field(s) … can never be filled"

A required column is protected, or left out of fields=, and you didn't pass it. Input can never fill it, so every insert would fail. This is usually a foreign key like author_id, or a field such as email that you protected but still need at signup. Pass it, or its relationship, as a trusted value: Post.create_or_abort(data, author=current_user). If clients should send it, don't protect it; see "Set once, read-only afterwards".

I got an IntegrityError from create_or_abort(), not a 409

It only turns unique-value conflicts into 409s. Anything else, such as a CHECK constraint, a foreign key to a missing row, or a trusted value you passed as None, is re-raised after the rollback so real bugs aren't hidden.

How do I change the error response format?

Register a handler for the exception class. It receives the field names on e.fields. See Error responses.

Does it work with type checkers?

Yes. The package ships py.typed and the class methods are generic over your model, so User.get(id) is User | None.