Models¶
Models are the center of the Saffier ORM API.
They define table structure, own field declarations, expose managers, and give you the persistence lifecycle you use throughout the rest of the framework.
Saffier deliberately keeps the declaration style close to Django while still building on top of SQLAlchemy Core underneath. That means the model layer is compact to read but still maps to explicit SQLAlchemy tables and expressions.
If you already have an existing database and want to reflect tables instead of declaring them, see reflection.
What is a model¶
A model in Saffier is a Python class whose declared fields describe a database table and whose instances represent rows from that table.
In practice, a model gives you three things at the same time:
- a schema declaration
- a query entry point through managers
- a persistence API on each instance
Declaring models¶
Declare models by inheriting from saffier.Model, adding field attributes, and
declaring a Meta class.
For each concrete model, Meta.registry is mandatory unless the model inherits
it from another concrete Saffier model.
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
"""
The user model representation
"""
id = saffier.IntegerField(primary_key=True)
name = saffier.CharField(max_length=255)
age = saffier.IntegerField(minimum=18)
is_active = saffier.BooleanField(default=True)
class Meta:
registry = models
A realistic model definition¶
The following pattern is a more representative application model than a bare two-field example:
class Team(saffier.Model):
id = saffier.IntegerField(primary_key=True, autoincrement=True)
name = saffier.CharField(max_length=120, unique=True)
is_active = saffier.BooleanField(default=True)
class Meta:
registry = models
class User(saffier.Model):
id = saffier.IntegerField(primary_key=True, autoincrement=True)
email = saffier.EmailField(max_length=255, unique=True)
display_name = saffier.CharField(max_length=120)
team = saffier.ForeignKey(
Team,
on_delete=saffier.RESTRICT,
related_name="members",
null=True,
)
created_at = saffier.DateTimeField(auto_now_add=True)
updated_at = saffier.DateTimeField(auto_now=True)
class Meta:
registry = models
tablename = "users"
That single declaration gives you:
- table generation metadata
- validators for each field
- the default
querymanager - reverse access via
team.members - model instance methods such as
save(),update(),delete(), andload()
StrictModel¶
When you want Saffier to stay Python-native but behave more defensively at runtime, inherit from
saffier.StrictModel.
StrictModel keeps the same ORM surface as Model, but it adds two rules:
- scalar field assignments are validated immediately;
- undeclared public attributes are rejected.
class Product(saffier.StrictModel):
id = saffier.IntegerField(primary_key=True, autoincrement=True)
name = saffier.CharField(max_length=100)
rating = saffier.IntegerField(minimum=1, maximum=5, default=1)
class Meta:
registry = models
Use Model when you want the looser Saffier behavior. Use StrictModel when you want Edgy-like
runtime discipline without introducing Pydantic.
Runtime schema selection¶
Saffier query managers also respect an explicit __using_schema__ override on the model class or
instance.
User.__using_schema__ = "tenant_a"
rows = await User.query.all()
user = User(id=1)
user.__using_schema__ = "tenant_b"
await user.load()
This keeps schema selection on the pure-Python model side and avoids mutating the registry-wide default schema just to run a scoped query.
Model dumping¶
model_dump() walks the declared Saffier fields instead of blindly serializing
__dict__.
This means:
- field declarations with
exclude=Trueare skipped during serialization; - many-to-many managers are not leaked into dumps;
- computed fields only appear by default when you declare them with
exclude=False; - secret-filtered querysets keep masked attributes out of
model_dump()until you explicitly reload them withload()orload_recursive().
Model engines¶
Saffier models can also expose an optional engine-backed representation.
This does not replace the model itself. It adds an adapter layer on top of the already-valid Saffier model behavior.
You can configure it:
- per registry with
Registry(model_engine="pydantic") - per model with
Meta.model_engine = "pydantic" - per model opt-out with
Meta.model_engine = False
When enabled, the model gains opt-in helpers such as:
to_engine_model()engine_dump()engine_dump_json()engine_validate()from_engine()engine_json_schema()
The normal ORM lifecycle stays the same. Querysets, relations, save/load, and
model_dump() are still owned by Saffier.
Built-in engine adapters currently include pydantic and msgspec.
See Model Engines for the full design, examples, custom-engine guide, guarantees, and migration notes.
Saffier models are a bit opinionated when it comes to ID and this is to maintain consistency
within the SQL tables with field names and lookups.
Attention¶
If no id is declared in the model, Saffier will automatically generate an id of type
BigIntegerField and automatically becoming the primary key.
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
"""
The user model representation.
The `id` is not provided and Saffier will automatically
generate a primary_key `id` BigIntegerField.
"""
name = saffier.CharField(max_length=255)
age = saffier.IntegerField(minimum=18)
is_active = saffier.BooleanField(default=True)
class Meta:
registry = models
Restrictions with primary keys¶
Primary keys should always be declared in an id field. If you create a different
primary_key within the model in a different attribute, it will raise an ImproperlyConfigured.
Primary keys must be always declared inside an ID attribute.
Let us go through some examples.
What you should not do¶
The below examples are the practices you should avoid and it will raise an error. The examples are applied to any field available in Saffier.
Declaring a model primary key different from ID¶
import uuid
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
name = saffier.CharField(max_length=255, primary_key=True, default=str(uuid.uuid4))
age = saffier.IntegerField(minimum=18)
is_active = saffier.BooleanField(default=True)
class Meta:
registry = models
This will raise ImproperlyConfigured as you cannot have two primary keys.
One is the auto-generated id and the second is the name.
You can only have one and it should always be called id.
Declaring a model with ID and without default¶
When declaring an id, unless the field type is IntegerField or
BigIntegerField, you should always declare a default or a
ValueError is raised.
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
id = saffier.CharField(max_length=255, primary_key=True)
age = saffier.IntegerField(minimum=18)
is_active = saffier.BooleanField(default=True)
class Meta:
registry = models
Since the deafult for a CharField is not provided, a ValueError
is raised.
What you should do¶
When it comes to primary keys, it is actually very simple and clean.
Declaring a model primary key with different field type¶
This is for an explicit primary_key that you don't want to be the default, for example, a
UUIDField.
import uuid
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
id = saffier.UUIDField(primary_key=True, default=uuid.uuid4)
age = saffier.IntegerField(minimum=18)
is_active = saffier.BooleanField(default=True)
class Meta:
registry = models
Declaring a model with default primary key¶
If you don't want to be bothered and you are happy with the defaults generated by Saffier,
then you can simply declare the model without the id.
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
age = saffier.IntegerField(minimum=18)
is_active = saffier.BooleanField(default=True)
class Meta:
registry = models
The Meta class¶
When declaring a model, it is crucial having the Meta class declared. There is where you
declare the metadata needed for your models.
Currently the available parameters for the meta are:
-
registry - The registry instance for where the model will be generated. This field is mandatory and it will raise an
ImproperlyConfigurederror if no registry is found. -
tablename - The name of the table in the database, not the class name.
Default:
name of class pluralised -
table_prefix - Prefix automatically prepended to generated table names.
Default:
None -
abstract - If the model is abstract or not. If is abstract, then it won't generate the database table.
Default:
False -
unique_together - The unique constrainsts for your model.
Default:
None -
indexes - The extra custom indexes you want to add to the model
-
constraints - Extra SQLAlchemy table constraints to attach to the generated table.
-
model_engine - Optional engine adapter for this model. Use a registered engine name such as
"pydantic"orFalseto opt out of a registry default.
Registry¶
Working with a registry is what makes Saffier dynamic and very flexible with the familiar interface we all love. Without the registry, the model doesn't know where it should get the data from.
Imagine a registry like a bridge because it does exactly that.
Let us see some examples in how to use the registry with simple design and with some more complex approaches.
In a nutshell¶
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
name = saffier.CharField(max_length=255)
is_active = saffier.BooleanField(default=True)
class Meta:
registry = models
As you can see, when declaring the registry and assigning it to models, that same models is
then used in the Meta of the model.
With inheritance¶
Yes, you can also use the model inheritance to help you out with your models and avoid repetition.
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class BaseModel(saffier.Model):
"""
The base model for all models using the `models` registry.
"""
class Meta:
registry = models
class User(BaseModel):
name = saffier.CharField(max_length=255)
is_active = saffier.BooleanField(default=True)
class Product(BaseModel):
user = saffier.ForeignKey(User, null=False, on_delete=saffier.CASCADE)
sku = saffier.CharField(max_length=255, null=False)
As you can see, the User and Product tables are inheriting from the BaseModel where the
registry was already declared. This way you can avoid repeating yourself over and over again.
This can be particularly useful if you have more than one registry in your system and you want
to split the bases by responsabilities.
With abstract classes¶
What if your class is abstract? Can you inherit the registry anyway?
Of course! That doesn't change anything with the registry.
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class BaseModel(saffier.Model):
"""
The base model for all models using the `models` registry.
"""
class Meta:
abstract = True
registry = models
class User(BaseModel):
name = saffier.CharField(max_length=255)
is_active = saffier.BooleanField(default=True)
class Product(BaseModel):
user = saffier.ForeignKey(User, null=False, on_delete=saffier.CASCADE)
sku = saffier.CharField(max_length=255, null=False)
Table name¶
This is actually very simple and also comes with defaults. When creating a model
if a tablename field in the Meta object is not declared, it will pluralise the python class.
Model without table name¶
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
"""
If the `tablename` is not declared in the `Meta`,
saffier will pluralise the class name.
This table will be called in the database `users`.
"""
name = saffier.CharField(max_length=255)
is_active = saffier.BooleanField(default=True)
class Meta:
registry = models
As mentioned in the example, because a tablename was not declared, Saffier will pluralise
the python class name User and it will become users in your SQL Database.
Model with a table name¶
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
name = saffier.CharField(max_length=255)
is_active = saffier.BooleanField(default=True)
class Meta:
tablename = "users"
registry = models
Here the tablename is being explicitly declared as users. Although it matches with a
puralisation of the python class name, this could also be something else.
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
name = saffier.CharField(max_length=255)
is_active = saffier.BooleanField(default=True)
class Meta:
tablename = "db_users"
registry = models
In this example, the User class will be represented by a db_users mapping into the database.
Tip
Calling tablename with a different name than your class it doesn't change the behaviour
in your codebase. The tablename is used solely for SQL internal purposes. You will
still access the given table in your codebase via main class.
Table prefix¶
When Meta.table_prefix is defined, Saffier prefixes the generated table name automatically.
import saffier
database = saffier.Database("<your-url>")
models = saffier.Registry(database=database)
class Product(saffier.Model):
name = saffier.CharField(max_length=100)
class Meta:
registry = models
table_prefix = "catalog"
This model maps to catalog_products. Prefixes are inherited from parent models unless overridden.
Abstract¶
As the name suggests, it is when you want to declare an abstract model.
Why do you need an abstract model in the first place? Well, for the same reason when you need to declare an abstract class in python but for this case you simply don't want to generate a table from that model declaration.
This can be useful if you want to hold common functionality across models and don't want to repeat yourself.
The way of declaring an abstract model in Saffier is by passing True to the abstract
attribute in the meta class.
In a nutshell¶
In this document we already mentioned abstract models and how to use them but let us use some more examples to be even clear.
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class BaseModel(saffier.Model):
class Meta:
abstract = True
registry = models
This model itself does not do much alone. This simply creates a BaseModel and declares the
registry as well as declares the abstract as True.
Use abstract models to hold common functionality¶
Taking advantage of the abstract models to hold common functionality is usually the common use case for these to be use in the first place.
Let us see a more complex example and how to use it.
import uuid
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class BaseModel(saffier.Model):
id = saffier.UUIDField(primary_key=True, default=uuid.uuid4)
name = saffier.CharField(max_length=255)
class Meta:
abstract = True
registry = models
def get_description(self):
"""
Returns the description of a record
"""
return getattr(self, "description", None)
class User(BaseModel):
"""
Inheriting the fields from the abstract class
as well as the Meta data.
"""
phone_number = saffier.CharField(max_length=15)
description = saffier.TextField()
def transform_phone_number(self):
# logic here for the phone number
...
class Product(BaseModel):
"""
Inheriting the fields from the abstract class
as well as the Meta data.
"""
sku = saffier.CharField(max_length=255)
description = saffier.TextField()
def get_sku(self):
# Logic to obtain the SKU
...
This is already quite a complex example where User and Product have both common functionality
like the id and description as well the get_description() function.
Limitations¶
You can do almost everything with abstract models and emphasis in almost.
Abstract models do not allow you to:
- Declare managers.
- Declare unique together
- Declare indexes
- Declare
constraints
This limitations are intentional as these operations should be done for models and not abstact models.
Metaclass helpers¶
Saffier models expose a few metaclass-level helpers that are useful for tooling and advanced runtime workflows:
Model.pknames- Tuple of primary-key field names.Model.pkcolumns- Tuple of primary-key column names.Model.table_schema(schema, update_cache=False)- Returns the SQLAlchemy table for a given schema, with per-model schema cache support.Model.transaction(...)- Opens a transaction using the model database connection.
Inheritance controls¶
You can opt fields out of concrete-model inheritance with inherit=False:
class Base(saffier.Model):
internal_code = saffier.CharField(max_length=50, inherit=False)
When a concrete child model inherits from Base, internal_code is not propagated to the child
model schema.
Unique together¶
This is a very powerful tool being used by almost every single SQL database out there and extremely useful for database design and integrity.
If you are not familiar with the concept, a unique together enforces records to be unique within those parameters when adding a record to a specific table.
Let us see some examples.
Simple unique together¶
The simplest and cleanest way of declaring a unique together. There are actually two ways of
declaring this simple unique. Via saffier field directly or via
unique_together in the meta class.
Within the saffier field¶
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
name = saffier.CharField(max_length=255)
email = saffier.EmailField(max_length=70, unique=True)
is_active = saffier.BooleanField(default=True)
class Meta:
registry = models
In the field you can declare directly unique and that is about it.
With unique_together¶
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
name = saffier.CharField(max_length=255)
email = saffier.EmailField(max_length=70)
is_active = saffier.BooleanField(default=True)
class Meta:
registry = models
unique_together = ["name"] # or ("name",)
The unique_together expects one of the following:
- List of strings.
- List of tuple of strings.
- List of tuples of strings.
- List of tuples of strings as well as strings
- A List of UniqueConstraint instances.
If none of these values are provided, it will raise a ValueError.
Complex unique together¶
Now, we all know that using simple uniques is easier if automatically declared within the saffier field an using the meta for only one field is overkill.
You take advantage of the unique_together when something more complex is needed and not limited
to one database field only.
When you need more than one field, independently, to be unique¶
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
name = saffier.CharField(max_length=255)
email = saffier.EmailField(max_length=70)
is_active = saffier.BooleanField(default=True)
class Meta:
registry = models
unique_together = ["name", "email"]
Now here is the tricky part. If you wanted to have together non-duplicate records with the
same email and name, this is not doing that. This is in fact saying unique emails and
unique names independent of each other.
This is useful but depends on each use case.
For this we used a list of strings.
When you need more than one field, together, to be unique¶
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
name = saffier.CharField(max_length=255)
email = saffier.EmailField(max_length=70)
is_active = saffier.BooleanField(default=True)
class Meta:
registry = models
unique_together = [("name", "email")]
Did you notice the difference? In this case, when you add a new record to the database it will
validate if the name and email together already exists. They are treated as one.
For this we used a list of tuple of strings.
When you need more than combined key, to be unique¶
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
name = saffier.CharField(max_length=255)
email = saffier.EmailField(max_length=70)
phone_number = saffier.CharField(max_length=15)
address = saffier.CharField(max_length=500)
is_active = saffier.BooleanField(default=True)
class Meta:
registry = models
unique_together = [
("name", "email"),
("name", "email", "phone_number"),
("email", "address"),
]
Now here is where the things get complex and exciting. As you can see, you can add different variations of the fields combined and generate with whatever complexity you need for your cases.
For this we used a list of tuples of strings.
When you want to mix it all¶
There are also cases when you want to mix it all up and this is also possible.
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
name = saffier.CharField(max_length=255)
email = saffier.EmailField(max_length=70)
phone_number = saffier.CharField(max_length=15)
address = saffier.CharField(max_length=500)
is_active = saffier.BooleanField(default=True)
class Meta:
registry = models
unique_together = [
("name", "email"),
("name", "email", "phone_number"),
("email", "address"),
"is_active",
]
Did you notice the different compared to the previous
example? This time we added a string is_active to the mix.
This will make sure that is_active is also unique
(although in general, for this case would not make too much sense).
For this we used a list of tuples of strings as well as strings.
When you use UniqueConstraint instances¶
This is another clean way of adding the unique together constrainst. This can be used also with the other ways of adding unique together shown in the above examples.
import saffier
from saffier import Database, Registry, UniqueConstraint
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
name = saffier.CharField(max_length=255)
email = saffier.EmailField(max_length=70)
phone_number = saffier.CharField(max_length=15)
address = saffier.CharField(max_length=500)
is_active = saffier.BooleanField(default=True)
class Meta:
registry = models
unique_together = [
UniqueConstraint(fields=["name", "email"]),
UniqueConstraint(fields=["name", "email", "phone_number"]),
UniqueConstraint(fields=["email", "address"]),
]
Or mixing both
import saffier
from saffier import Database, Registry, UniqueConstraint
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
name = saffier.CharField(max_length=255)
email = saffier.EmailField(max_length=70)
phone_number = saffier.CharField(max_length=15)
address = saffier.CharField(max_length=500)
is_active = saffier.BooleanField(default=True)
class Meta:
registry = models
unique_together = [
UniqueConstraint(fields=["name", "email"]),
("name", "email", "phone_number"),
("email", "address"),
]
Indexes¶
Sometimes you might want to add specific designed indexes to your models. Database indexes also somes with costs and you should always be careful when creating one.
If you are familiar with indexes you know what this means but if you are not, just have a quick read and get yourself familiar.
There are different ways of declaring an index.
Saffier provides an Index object that must be used when declaring models indexes or a
ValueError is raised.
from saffier import Index
Parameters¶
The Index parameters are:
- fields - List of model fields in a string format.
- name - The name of the new index. If no name is provided, it will generate one, snake case
with a suffix
_idxin the end. Example:name_email_idx. - suffix - The suffix used to generate the index name when the
namevalue is not provided.
Let us see some examples.
Simple index¶
The simplest and cleanest way of declaring an index with Saffier. You declare it directly in the model field.
import saffier
from saffier import Database, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
name = saffier.CharField(max_length=255)
email = saffier.EmailField(max_length=70, index=True)
is_active = saffier.BooleanField(default=True)
class Meta:
registry = models
With indexes in the meta¶
import saffier
from saffier import Database, Index, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
name = saffier.CharField(max_length=255)
email = saffier.EmailField(max_length=70)
is_active = saffier.BooleanField(default=True)
class Meta:
registry = models
indexes = [Index(fields=["email"])]
With complex indexes in the meta¶
import saffier
from saffier import Database, Index, Registry
database = Database("sqlite:///db.sqlite")
models = Registry(database=database)
class User(saffier.Model):
name = saffier.CharField(max_length=255)
email = saffier.EmailField(max_length=70)
is_active = saffier.BooleanField(default=True)
status = saffier.CharField(max_length=255)
class Meta:
registry = models
indexes = [
Index(fields=["name", "email"]),
Index(fields=["is_active", "status"], name="active_status_idx"),
]
Constraints¶
Use Meta.constraints for table-level SQLAlchemy constraints:
import sqlalchemy
import saffier
class Invoice(saffier.Model):
total = saffier.IntegerField()
class Meta:
registry = models
constraints = [
sqlalchemy.CheckConstraint("total >= 0", name="invoice_total_non_negative")
]
Meta.constraints accepts a list/tuple of sqlalchemy.Constraint instances.