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.
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-modelkitRequirements:
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.
-
Define a model
Put the mixin before
db.Model. You only declare your own columns;id,created_atandupdated_atcome from the mixin.models.pyfrom 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 -
Wire up the app and create the tables
app.pyfrom 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 -
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) -
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_hashin the output: it is asecret_column, so it is never sent. Sending"is_admin": trueor 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): # ✗ wrongPython 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.
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.
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# A project rule that attaches automatically to Python files
mkdir -p .cursor/rules
{ printf -- '---\ndescription: How to use flask-modelkit models, fields and endpoints\nglobs: **/*.py\nalwaysApply: false\n---\n\n'
curl -fsSL https://anoziechibuike.github.io/FlaskModelKit/AGENT.md; } > .cursor/rules/flask-modelkit.mdc# Path-specific instructions, applied to Python files (VS Code, JetBrains, github.com)
mkdir -p .github/instructions
{ printf -- '---\napplyTo: "**/*.py"\n---\n\n'
curl -fsSL https://anoziechibuike.github.io/FlaskModelKit/AGENT.md; } > .github/instructions/flask-modelkit.instructions.md# AGENTS.md is read by Codex, Jules, Amp, Zed and other agents
curl -fsSL https://anoziechibuike.github.io/FlaskModelKit/AGENT.md >> AGENTS.md
# Gemini CLI reads GEMINI.md instead
curl -fsSL https://anoziechibuike.github.io/FlaskModelKit/AGENT.md >> GEMINI.mdBefore writing any code, read https://anoziechibuike.github.io/FlaskModelKit/AGENT.md
and follow it for everything that uses flask-modelkit in this conversation.Commit the file it creates so everyone on your team, and every CI agent, gets the same rules.
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()andupdate(), nevercreate(**request.json). - Hidden vs protected:
secret_columnfor secrets,protected_columnfor 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 oflen(Model.all()).
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.
Your model
Table columns
Methods & properties
| Mixin | Adds |
|---|---|
BaseModelMixin | UUIDMixin + TimestampMixin + CRUDMixin + SerializerMixin |
UUIDMixin | id: UUID4 string primary key, set when the object is constructed |
IntIdMixin | id: auto-increment integer primary key, set by the database on insert |
TimestampMixin | created_at, updated_at in UTC |
SoftDeleteMixin | deleted_at, soft_delete(), restore(), is_deleted; hides deleted rows |
CRUDMixin | create, save, update, delete, get, filters, counts, paginate |
SerializerMixin | to_dict(), hidden fields |
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()Add soft delete by putting SoftDeleteMixin in front: class Article(SoftDeleteMixin, BaseModelMixin, db.Model).
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.
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 idStrings 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")).
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.
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 # 1Use one primary-key mixin per model: UUIDMixin or IntIdMixin, never both.
TimestampMixin
Adds created_at and updated_at, both in UTC. Both are set to the same instant when the object is constructed.
Set once, on construction. Protected from update().
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 # TrueValues 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()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.
NULL while the row is live; the deletion time (UTC) once soft-deleted. Protected from update().
True when deleted_at is set.
Sets deleted_at to now. The row stays in the table.
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()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():).
| Write | Read | Count / check |
|---|---|---|
create(), create_or_abort() | get() | count_all() |
save() | get_or_404() | filter_and_count_all() |
update() | filter_one() | exists() |
delete() | filter_all() | paginate() |
close() | all(), select() |
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.
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 responseSee Serialisation for every option, Hidden fields for keeping columns out of responses, and Hidden vs protected for secrets.
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)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.
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))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 rowupdate() 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(**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.
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- data
- Untrusted input, usually
request.get_json(silent=True).Noneis 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(). WithFalsethe row is still flushed, so conflicts are reported immediately. - **values
- Trusted values such as
author=current_useror a password hash. They overridedataand skip protection.
What happens, in order
Check the body is an object
A list or string raises
InvalidBodyError(400).Drop unsafe keys
Keys that aren't mapped attributes, or are protected, are removed. With
fields=, anything not listed is removed too.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, aTypeErrorexplains 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.Check required fields
Every
NOT NULLcolumn without a default is required, plus any names inrequired=. 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 withMissingFieldsError(400).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:
| Exception | Status | error | When |
|---|---|---|---|
InvalidBodyError | 400 | invalid_body | Input is not an object |
MissingFieldsError | 400 | missing_fields | Required fields missing or blank |
DuplicateValueError | 409 | duplicate | A 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}, 400Types 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.
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 defaultWithout 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 default | Why |
|---|---|
id | Changing a primary key would orphan related rows |
created_at, updated_at | Timestamps must come from the server's clock |
deleted_at | Clients 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!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 trustedCommon cases
| Case | Example fields | Do this |
|---|---|---|
| Permissions | is_admin, role, email_verified | protected_column() |
| Ownership | author_id and author | Protect the foreign key and the relationship. Set the owner as a trusted value. |
| Server-computed values | total, balance, slug | protected_column(), then compute them in your code |
| Child collections | comments, posts | info={"protected": True} on the relationship |
| Secrets | password_hash, api_key | secret_column(): protected and hidden (why both) |
| Set once, read-only afterwards | email, username | Don'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)), 201Server-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))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".
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 nothingcreate_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.
Reading & filtering
| Method | Returns | SQL |
|---|---|---|
Model.get(id) | object or None | by primary key |
Model.get_or_404(id) | object, or HTTP 404 | by primary key |
Model.all() | list[Model] | SELECT |
Model.filter_one(**f) | first match or None | LIMIT 1 |
Model.filter_all(**f) | list[Model] | WHERE … |
Model.exists(**f) | bool | EXISTS |
Model.count_all() | int | COUNT |
Model.filter_and_count_all(**f) | int | COUNT … WHERE |
Model.paginate(page, per_page) | PaginationReturn | LIMIT/OFFSET + COUNT |
Model.select() | Select to build on | none 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 queryCounts and existence checks run in the database, so they never load rows into Python.
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
itemslist, not an error. - The return value is a plain dict, typed as
PaginationReturn, so you can replaceitemsbefore 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.
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 objectModel.select(include_deleted=True) returns the same statement with soft-deleted rows included.
Serialisation
- 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 value | JSON value | Example |
|---|---|---|
datetime, date, time | ISO 8601 string | "2026-11-07T10:15:00" |
Decimal | string (no precision lost) | "9.50" |
UUID | string | "3f1c…" |
Enum | its .value | "active" |
bytes | UTF-8 string | "…" |
return jsonify(user.to_dict(json_safe=True))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 bookauthor.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 chaptersEach 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 booksOn models with SoftDeleteMixin, soft-deleted related objects are left out automatically.
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 tooIncluding 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 goneLimits
update()can't undo a deletion.deleted_atis 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 onemail. 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 rawtext()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")),
)Migrations
The mixins are ordinary SQLAlchemy columns, so Flask-Migrate (Alembic) picks them up like any other column.
Install and register
# pip install Flask-Migrate from flask_migrate import Migrate migrate = Migrate(app, db)Create the migrations folder (once)
flask db initGenerate and apply
flask db migrate -m "add posts" flask db upgrade
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;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()import enum
from flask_modelkit import BaseModelMixin, SoftDeleteMixin, protected_column, secret_column
from extensions import db
class Model(SoftDeleteMixin, BaseModelMixin, db.Model):
"""Every table: UUID id, timestamps, soft delete, CRUD, to_dict()."""
__abstract__ = True
class User(Model):
name = db.Column(db.String(80), nullable=False)
# Not protected: signup must accept it. Profile edits use an allowlist instead.
email = db.Column(db.String(120), unique=True, nullable=False)
is_admin = protected_column(db.Boolean, default=False, nullable=False)
password_hash = secret_column(db.String(255), nullable=False)
posts = db.relationship("Post", back_populates="author", info={"protected": True})
class PostStatus(enum.Enum):
DRAFT = "draft"
PUBLISHED = "published"
class Post(Model):
# Clients may edit title/body/status, never ownership or children.
title = db.Column(db.String(200), nullable=False)
body = db.Column(db.Text, default="")
status = db.Column(db.Enum(PostStatus), default=PostStatus.DRAFT, nullable=False)
author_id = protected_column(db.String(126), db.ForeignKey("user.id"), nullable=False)
author = db.relationship("User", back_populates="posts", info={"protected": True})
comments = db.relationship("Comment", back_populates="post", info={"protected": True})
class Comment(Model):
body = db.Column(db.String(1000), nullable=False)
post_id = protected_column(db.String(126), db.ForeignKey("post.id"), nullable=False)
post = db.relationship("Post", back_populates="comments", info={"protected": True})from flask import Flask
from extensions import db, migrate
def create_app():
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///blog.db"
db.init_app(app)
migrate.init_app(app, db)
from routes import bp
app.register_blueprint(bp)
return appfrom flask import Blueprint, abort, jsonify, request
from werkzeug.security import generate_password_hash
from extensions import db
from models import Comment, Post, PostStatus, User
bp = Blueprint("api", __name__)
def body():
return request.get_json(silent=True) or {}
# ---- users ---------------------------------------------------------------
@bp.post("/users")
def register():
data = body()
pw = data.get("password")
# 400 lists missing name/email/password · 409 if email is taken
# is_admin and password_hash are protected: a client can't sign up as
# admin or choose its own hash. The hash is passed as a trusted value.
user = User.create_or_abort(
data,
required=["password"],
password_hash=generate_password_hash(pw) if pw else None,
)
return jsonify(user.to_dict(depth=0, json_safe=True)), 201
PROFILE_FIELDS = {"name"} # email is set at signup, then changed only by verification
@bp.patch("/users/<id>")
def edit_profile(id):
user = User.get_or_404(id)
user.update({k: v for k, v in body().items() if k in PROFILE_FIELDS})
return jsonify(user.to_dict(depth=0, json_safe=True))
# ---- posts ---------------------------------------------------------------
@bp.post("/users/<user_id>/posts")
def create_post(user_id):
author = User.get_or_404(user_id)
# title required; author_id comes from the trusted `author=` value
post = Post.create_or_abort(body(), fields=["title", "body"], author=author)
return jsonify(post.to_dict(json_safe=True)), 201
@bp.get("/posts")
def list_published():
# paginate() has no filters, so build the query and use db.paginate
stmt = (Post.select()
.where(Post.status == PostStatus.PUBLISHED)
.order_by(Post.created_at.desc()))
page = db.paginate(stmt, page=request.args.get("page", 1, type=int),
per_page=10, error_out=False)
return jsonify({
"items": [p.to_dict(depth=0, json_safe=True) for p in page.items],
"total": page.total, "page": page.page, "pages": page.pages,
})
@bp.get("/posts/<id>")
def read_post(id):
# depth=1 → author (without password_hash) and live comments
return jsonify(Post.get_or_404(id).to_dict(json_safe=True))
@bp.patch("/posts/<id>")
def edit_post(id):
post = Post.get_or_404(id)
data = body()
if "status" in data:
data["status"] = PostStatus(data["status"]) # "published" → enum
post.update(data) # author_id, id, timestamps can't change
return jsonify(post.to_dict(depth=0, json_safe=True))
@bp.delete("/posts/<id>")
def trash_post(id):
Post.get_or_404(id).soft_delete()
return "", 204
@bp.get("/trash")
def trash():
stmt = Post.select(include_deleted=True).where(Post.deleted_at.is_not(None))
return jsonify([p.to_dict(depth=0, json_safe=True)
for p in db.session.scalars(stmt)])
@bp.post("/posts/<id>/restore")
def restore_post(id):
post = Post.get(id, include_deleted=True) or abort(404)
post.restore()
return jsonify(post.to_dict(depth=0, json_safe=True))
# ---- comments ------------------------------------------------------------
@bp.post("/posts/<id>/comments")
def add_comment(id):
post = Post.get_or_404(id) # trashed posts 404 automatically
comment = Comment.create_or_abort(body(), post=post) # 400 if body is blank
return jsonify(comment.to_dict(depth=0, json_safe=True)), 201Run 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 runWhat 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
| Behaviour | Provided by |
|---|---|
No password_hash anywhere in JSON, even nested under author | secret_column() (hidden) |
| Signup can't create an admin or set its own password hash | protected_column(), secret_column() |
| PATCH can't move a post to another author, swap its comments, or forge timestamps | protected_column(), info={"protected": True} |
| Email can be set at signup but not through a profile edit | Allowlist passed to update() |
| Signup answers 400 with the missing fields, or 409 for a taken email | create_or_abort() |
Deleting a post moves it to /trash; its URL then 404s | SoftDeleteMixin + get_or_404() |
Deleted comments disappear from post.comments | Soft-delete filter on relationship loads |
"status": "published" instead of an enum object | to_dict(json_safe=True) |
Post doesn't list itself again inside comments[].post | Cycle-safe to_dict() |
Clean flask db migrate output | Python-side defaults |
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.
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)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.
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"}, …]API reference
Every public name in flask_modelkit. T means "your model class".
CRUDMixin · instance methods
Adds the object to the session and commits. With commit=False, only adds it; you commit later.
Sets each key that is a mapped column or relationship and not protected, then calls save(). Other keys are ignored silently.
Removes the row from the database. This is a hard delete, even on SoftDeleteMixin models.
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
Constructs cls(**kwargs), saves it, and returns it. Protection doesn't apply: it is for trusted code, so it can set protected fields.
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.
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.
Like get(), but aborts the request with 404 and the description "<Model> not found".
Every live row. Prefer paginate() for tables that grow.
First row matching all column=value pairs (LIMIT 1).
All rows matching all column=value pairs.
Number of live rows, computed with SQL COUNT.
Number of rows matching the filters, with SQL COUNT.
Whether any row matches, with SQL EXISTS.
One page, ordered by created_at (when present) then primary key. Out-of-range pages return empty items.
A select(Model) statement to build custom queries on. include_deleted=True sets the execution option that shows soft-deleted rows.
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}.
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
Columns and relationships as a dict. See Serialisation.
Names never serialised, combined with columns and relationships marked info={"hidden": True}. Default: empty.
SoftDeleteMixin
Sets deleted_at = utcnow() and saves.
Sets deleted_at = None and saves.
deleted_at is not None.
Pass via .execution_options(include_deleted=True) on any ORM statement to include soft-deleted rows.
Functions & types
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().
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.
Current UTC time as a naive datetime. The clock every timestamp in the package uses.
- items
listof 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
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.
The bundled Flask-SQLAlchemy instance. Call db.init_app(app).
Abstract BaseModelMixin model bound to the bundled db.
Column reference
| Column | Mixin | Type | Default | Notes |
|---|---|---|---|---|
id | UUIDMixin | String(126) PK | uuid4() on construct | Pass id= to override |
id | IntIdMixin | Integer PK | database autoincrement | None until flush |
created_at | TimestampMixin | DateTime NOT NULL | utcnow() on construct | Naive UTC |
updated_at | TimestampMixin | DateTime NOT NULL | utcnow(), refreshed on UPDATE | Naive UTC |
deleted_at | SoftDeleteMixin | DateTime NULL, indexed | NULL | Set 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.
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
Show that you use it
Add a badge to your project's README:
[](https://anoziechibuike.github.io/FlaskModelKit/)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.