Fields
When you define a model in mydborm, every class attribute you write is a field — a small object that describes one column in the database table. A field does three jobs at once:
- It tells mydborm what SQL column to create (
INT,VARCHAR(50),TEXT, and so on) when you callcreate_table(). - It checks values before they're saved — so a typo like passing the
string
"abc"to a number field fails immediately in Python, instead of confusing the database later. - It can apply extra rules (uniqueness, defaults, required-ness) without you writing any SQL yourself.
from mydborm import BaseModel, IntField, StrField
class Product(BaseModel):
__tablename__ = "products"
id = IntField(primary_key=True)
name = StrField(max_length=100, nullable=False)
Here, id and name are fields. IntField and StrField are two of
the many field types mydborm provides — each one matches a kind of
data you'd store (whole numbers, short text, true/false, dates, and so
on).
Picking the right field type
The simplest rule of thumb: pick the field type that matches the shape of your data, not the database column name you'd normally type by hand.
- Storing a whole number (an age, a quantity, an ID)? Use
IntField(or one of its bigger/smaller variants — see Integer variants). - Storing short text with a known maximum length (a username, an
email)? Use
StrField. - Storing a number with decimal places? Use
FloatFieldfor things like scores or measurements where tiny rounding differences don't matter, orDecimalFieldfor money, where they do. - Storing true/false? Use
BoolField. - Storing a date, a time, or both? Use
DateField,TimeField, orDateTimeField.
Every field type below follows the same pattern: you import it, use it as a class attribute on your model, and pass it some options that control how strict or flexible that column is.
Options every field accepts
Regardless of which field type you pick, you can pass these keyword arguments to any of them:
| Option | Type | Default | What it does |
|---|---|---|---|
primary_key |
bool | False |
Marks this column as the table's unique identifier. mydborm auto-generates the value for you (an auto-incrementing number), so you never set it yourself when creating a row. |
nullable |
bool | True |
Whether this column is allowed to be empty (None in Python, NULL in SQL). Set nullable=False to make a field required. |
default |
any | None |
The value to use automatically if you don't supply one when creating a row. |
unique |
bool | False |
If True, the database rejects any row that would duplicate an existing value in this column (handy for things like usernames or emails). |
index |
bool | False |
If True, mydborm creates a database index on this column, which speeds up searches and filters on it at the cost of slightly slower writes. |
validators |
list | [] |
A list of extra validation rules to run before saving — see Custom validators below. |
A quick note on terminology: throughout this page you'll see "MySQL"
and "YugabyteDB" column types side by side. mydborm supports both
databases (plus PostgreSQL, which uses the same types as YugabyteDB),
and it automatically translates each field into the right SQL syntax
for whichever one you've connected to with db.configure(). You don't
need to remember the SQL names yourself — they're shown here mostly so
you know what's actually being created under the hood.
All field types at a glance
| Field | Python type | MySQL | YugabyteDB / PostgreSQL |
|---|---|---|---|
IntField |
int |
INT |
INTEGER |
StrField(max_length=n) |
str |
VARCHAR(n) |
VARCHAR(n) |
TextField |
str |
TEXT |
TEXT |
BoolField |
bool |
TINYINT(1) |
BOOLEAN |
FloatField |
float |
FLOAT |
FLOAT |
DecimalField(precision, scale) |
Decimal |
DECIMAL(p,s) |
DECIMAL(p,s) |
DateField |
date |
DATE |
DATE |
DateTimeField |
datetime |
DATETIME |
DATETIME |
JSONField |
dict/list |
JSON |
JSONB |
ForeignKeyField(to=...) |
int |
INT |
INTEGER |
TinyIntField |
int |
TINYINT |
SMALLINT |
SmallIntField |
int |
SMALLINT |
SMALLINT |
BigIntField |
int |
BIGINT |
BIGINT |
UnsignedBigIntField |
int |
BIGINT UNSIGNED |
NUMERIC(20) |
DoubleField |
float |
DOUBLE |
DOUBLE PRECISION |
BitField(length=n) |
int/str |
BIT(n) |
BIT(n) |
CharField(length=n) |
str |
CHAR(n) |
CHAR(n) |
TinyTextField |
str |
TINYTEXT |
TEXT |
MediumTextField |
str |
MEDIUMTEXT |
TEXT |
LongTextField |
str |
LONGTEXT |
TEXT |
BinaryField(length=n) |
bytes |
BINARY(n) |
BYTEA |
VarBinaryField(max_length=n) |
bytes |
VARBINARY(n) |
BYTEA |
BlobField |
bytes |
BLOB/MEDIUMBLOB/LONGBLOB |
BYTEA |
TimeField |
time |
TIME |
TIME |
TimestampField |
datetime |
TIMESTAMP |
TIMESTAMPTZ |
EnumField(choices=[...]) |
str |
ENUM(...) |
VARCHAR(n) |
SetField(choices=[...]) |
str/list |
SET(...) |
TEXT[] |
PasswordField |
str |
VARCHAR(255) |
VARCHAR(255) |
EncryptedField |
str |
TEXT |
TEXT |
The most common fields you'll reach for in everyday models — IntField,
StrField, TextField, BoolField, FloatField, DecimalField,
DateField, DateTimeField, JSONField, and ForeignKeyField — are
covered in detail first. The rest are variants for more specific
situations (bigger numbers, fixed-size codes, binary data, and so on)
and are grouped together near the end of this page.
IntField
IntField stores a whole number and becomes INT in MySQL or
INTEGER in YugabyteDB/PostgreSQL. Reach for it first whenever you
need IDs, counts, ages, or quantities — it's the field type you'll use
most often.
from mydborm import BaseModel, IntField
class Product(BaseModel):
__tablename__ = "products"
id = IntField(primary_key=True) # AUTO_INCREMENT / SERIAL
quantity = IntField(nullable=False)
views = IntField(default=0)
rating = IntField(nullable=True)
When primary_key=True, mydborm makes the column auto-increment — the
database assigns the next number for you, so you never set id
yourself when creating a row.
SQL generated:
-- MySQL
id INT PRIMARY KEY AUTO_INCREMENT,
quantity INT NOT NULL,
views INT DEFAULT 0,
rating INT
-- YugabyteDB
id SERIAL PRIMARY KEY,
quantity INTEGER NOT NULL,
views INTEGER DEFAULT 0,
rating INTEGER
If you pass a value that doesn't make sense for an integer column,
IntField raises an error before any SQL is sent — that's the
"validation" mentioned earlier in action:
from mydborm import IntField
f = IntField(nullable=False)
f.name = "quantity"
f.validate(42) # OK → 42
f.validate(None) # ERROR → ValueError: Field 'quantity' cannot be None
f.validate("abc") # ERROR → TypeError: wrong type
Real-world example:
class InventoryItem(BaseModel):
__tablename__ = "inventory"
id = IntField(primary_key=True)
product_id = IntField(nullable=False)
warehouse_id = IntField(nullable=False)
quantity = IntField(nullable=False, default=0)
reorder_at = IntField(nullable=True) # reorder when qty drops below this
item_id = InventoryItem.create(
product_id = 1,
warehouse_id = 5,
quantity = 100,
reorder_at = 10,
)
StrField
StrField stores text up to a fixed maximum length and becomes
VARCHAR(n) in SQL — n being whatever number you pass as
max_length. Use it for anything that's text but has a sensible upper
bound: names, emails, short codes, usernames. If max_length isn't
given, it defaults to 255.
from mydborm import StrField
class User(BaseModel):
__tablename__ = "users"
id = IntField(primary_key=True)
username = StrField(max_length=50, nullable=False, unique=True)
email = StrField(max_length=255, nullable=False, unique=True)
nickname = StrField(max_length=50, nullable=True, default="anonymous")
locale = StrField(max_length=5, nullable=True, default="en")
SQL generated:
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
nickname VARCHAR(50) DEFAULT 'anonymous',
locale VARCHAR(5) DEFAULT 'en'
max_length isn't just decoration — mydborm enforces it in Python too,
so you find out about an over-long value right away instead of waiting
for the database to complain:
f = StrField(max_length=10, nullable=False)
f.name = "username"
f.validate("alice") # OK → "alice"
f.validate("a" * 11) # ERROR → exceeds max_length=10
f.validate(None) # ERROR → cannot be None
f.validate(123) # ERROR → TypeError: expects str, got int
Real-world example — product catalog:
class Product(BaseModel):
__tablename__ = "products"
id = IntField(primary_key=True)
sku = StrField(max_length=20, nullable=False, unique=True, index=True)
name = StrField(max_length=100, nullable=False)
brand = StrField(max_length=50, nullable=True)
color = StrField(max_length=30, nullable=True)
size = StrField(max_length=10, nullable=True)
category = StrField(max_length=50, nullable=True, index=True)
# Query by indexed fields is fast
laptops = Product.query().where("category", "Electronics").where("brand", "Apple").all()
TextField
TextField stores text with no length limit, becoming a TEXT column
in both MySQL and YugabyteDB. Use it for anything that could run long
— article bodies, descriptions, freeform notes — where picking a
max_length for StrField wouldn't make sense.
from mydborm import TextField
class Article(BaseModel):
__tablename__ = "articles"
id = IntField(primary_key=True)
title = StrField(max_length=200, nullable=False)
body = TextField(nullable=False) # unlimited
summary = StrField(max_length=500, nullable=True) # short excerpt
notes = TextField(nullable=True) # internal notes
aid = Article.create(
title = "Getting started with mydborm",
body = "This is a very long article... " * 1000,
summary = "A quick intro to mydborm",
)
Note
TextField doesn't take a max_length argument — that's the
whole point of it. If you need a short, searchable string with a
known limit, use StrField instead; if the content could be
arbitrarily long, use TextField.
BoolField
BoolField stores a true/false value. It becomes TINYINT(1) in
MySQL (where the database itself only understands 1/0) or native
BOOLEAN in YugabyteDB/PostgreSQL — but in your Python code you should
always write True/False, never 1/0. mydborm handles translating
between the two automatically.
from mydborm import BoolField
class User(BaseModel):
__tablename__ = "users"
id = IntField(primary_key=True)
active = BoolField(default=True)
verified = BoolField(default=False)
is_admin = BoolField(nullable=False, default=False)
newsletter = BoolField(nullable=True)
SQL generated:
-- MySQL
active TINYINT(1) DEFAULT 1,
verified TINYINT(1) DEFAULT 0,
is_admin TINYINT(1) NOT NULL DEFAULT 0
-- YugabyteDB
active BOOLEAN DEFAULT TRUE,
verified BOOLEAN DEFAULT FALSE,
is_admin BOOLEAN NOT NULL DEFAULT FALSE
Passing anything other than an actual Python bool is rejected — even
numbers, even though MySQL itself stores booleans as numbers under the
hood:
f = BoolField(nullable=False)
f.name = "active"
f.validate(True) # OK → True
f.validate(False) # OK → False
f.validate(None) # ERROR → cannot be None
f.validate("yes") # ERROR → TypeError: expects bool, got str
f.validate(1) # ERROR → TypeError: expects bool, got int
Dialect difference
MySQL stores booleans as TINYINT(1) and would normally hand you
back 1/0 if you queried it directly. YugabyteDB returns native
True/False. mydborm smooths this difference over for you — as
long as you stick to True/False in your own code, you don't
need to worry about which database you're using.
Real-world example — feature flags:
class FeatureFlag(BaseModel):
__tablename__ = "feature_flags"
id = IntField(primary_key=True)
name = StrField(max_length=50, nullable=False, unique=True)
enabled = BoolField(default=False)
beta_only = BoolField(default=True)
description = StrField(max_length=255, nullable=True)
# Toggle a feature
FeatureFlag.update({"enabled": True}, name="dark_mode")
# Get all enabled features
enabled = FeatureFlag.filter(enabled=True)
FloatField
FloatField stores a number with a decimal point and becomes a
FLOAT column. It's a good fit for things like measurements, scores,
or ratings, where tiny rounding differences don't matter. For money or
anything where exact precision matters, use DecimalField
instead — see why in the next section.
from mydborm import FloatField
class Product(BaseModel):
__tablename__ = "products"
id = IntField(primary_key=True)
price = FloatField(nullable=False)
weight = FloatField(nullable=True) # kg
rating = FloatField(nullable=True, default=0.0) # 0.0–5.0
Whole numbers passed in are automatically converted to floats for you:
f = FloatField(nullable=False)
f.name = "price"
f.validate(9.99) # OK → 9.99
f.validate(10) # OK → 10.0 (coerced from int)
f.validate("bad") # ERROR → TypeError
f.validate(None) # ERROR → cannot be None
Real-world example — analytics:
class PageView(BaseModel):
__tablename__ = "page_views"
id = IntField(primary_key=True)
url = StrField(max_length=500, nullable=False)
load_time = FloatField(nullable=True) # seconds
scroll_pct = FloatField(nullable=True) # 0.0–100.0
bounce = BoolField(default=False)
# Average load time per URL
rows = db.fetchall(
"SELECT url, AVG(load_time) AS avg_ms FROM page_views GROUP BY url ORDER BY avg_ms DESC LIMIT 10"
)
DecimalField
DecimalField stores a fixed-precision number and becomes
DECIMAL(precision, scale) in SQL. precision is the total number of
digits allowed, and scale is how many of those digits come after the
decimal point. If you don't pass either, they default to precision=10,
scale=2 — good enough for most prices.
from mydborm import DecimalField
class Order(BaseModel):
__tablename__ = "orders"
id = IntField(primary_key=True)
subtotal = DecimalField(precision=10, scale=2, nullable=False) # 99999999.99
tax = DecimalField(precision=10, scale=2, nullable=False)
discount = DecimalField(precision=10, scale=2, default=0.00)
total = DecimalField(precision=10, scale=2, nullable=False)
currency = StrField(max_length=3, default="USD")
Always use DecimalField for money
Computers store regular floating-point numbers (FloatField) in a
way that can introduce tiny rounding errors — for example,
0.1 + 0.2 comes out to 0.30000000000000004, not 0.3. That's
harmless for things like ratings, but unacceptable for money.
DecimalField uses Python's Decimal type instead, which is
exact: 0.1 + 0.2 = 0.3, every time.
Real-world example:
from decimal import Decimal
oid = Order.create(
subtotal = Decimal("99.99"),
tax = Decimal("8.00"),
discount = Decimal("10.00"),
total = Decimal("97.99"),
currency = "USD",
)
order = Order.get(id=oid)
print(order["total"]) # Decimal('97.99') — exact!
DateField
DateField stores a calendar date — year, month, day — with no time
of day attached, and becomes a DATE column. Use it for things like
birthdays or hire dates, where the time of day is irrelevant or
unknown.
from mydborm import DateField
class Employee(BaseModel):
__tablename__ = "employees"
id = IntField(primary_key=True)
name = StrField(max_length=100, nullable=False)
hired_on = DateField(nullable=False)
left_on = DateField(nullable=True)
birthday = DateField(nullable=True)
You can pass a Python date object (recommended) or a date string —
both are accepted:
from datetime import date
eid = Employee.create(
name = "Alice Smith",
hired_on = date(2024, 1, 15),
birthday = date(1990, 6, 20),
)
emp = Employee.get(id=eid)
print(emp["hired_on"]) # 2024-01-15
print(type(emp["hired_on"])) # <class 'datetime.date'>
# Query employees hired this year
import datetime
year_start = date(datetime.date.today().year, 1, 1)
new_hires = Employee.query().where("hired_on__gte", year_start).all()
print(f"New hires this year: {len(new_hires)}")
# Find employees with birthdays this month
this_month = date.today().month
# Use raw SQL for complex date functions
rows = db.fetchall(
"SELECT * FROM employees WHERE MONTH(birthday) = %s",
[this_month]
)
DateTimeField
DateTimeField stores both a date and a time of day, becoming
DATETIME in MySQL or TIMESTAMP in YugabyteDB/PostgreSQL. Use it
whenever you need to know not just that something happened, but
when — log entries, "created at" timestamps, scheduled events.
from mydborm import DateTimeField
class AuditLog(BaseModel):
__tablename__ = "audit_logs"
id = IntField(primary_key=True)
user_id = IntField(nullable=False)
action = StrField(max_length=50, nullable=False)
table_name = StrField(max_length=50, nullable=False)
record_id = IntField(nullable=True)
created_at = DateTimeField(nullable=True)
Real-world example:
from datetime import datetime, timedelta
# Log an action
log_id = AuditLog.create(
user_id = 1,
action = "UPDATE",
table_name = "products",
record_id = 42,
created_at = datetime.now(),
)
# Find recent activity — last 24 hours
cutoff = datetime.now() - timedelta(hours=24)
recent = (AuditLog.query()
.where("created_at__gte", cutoff)
.order_by("created_at", desc=True)
.all())
print(f"Actions in last 24h: {len(recent)}")
# Serialise datetime to JSON
log = AuditLog.get(id=log_id)
j = log.to_json() # datetime auto-converted to ISO string
print(j)
# {"id": 1, "created_at": "2024-06-19T10:30:00", ...}
If you need a timestamp that's aware of time zones (rather than just a
plain date and time), see TimestampField
further down.
JSONField
JSONField stores structured data — nested dictionaries, lists,
whatever shape you need — directly in a column, becoming JSON in
MySQL or JSONB (a faster, indexable binary form of JSON) in
YugabyteDB. It's useful for things you don't want to model as separate
columns: per-user settings, flexible metadata, tags.
from mydborm import JSONField
class UserProfile(BaseModel):
__tablename__ = "user_profiles"
id = IntField(primary_key=True)
user_id = IntField(nullable=False, unique=True)
settings = JSONField(nullable=False)
preferences = JSONField(nullable=True)
tags = JSONField(nullable=True)
metadata = JSONField(nullable=True)
You can store dictionaries or lists, and read/write nested values just like any other Python data structure:
# Store nested config
uid = UserProfile.create(
user_id = 1,
settings = {
"theme": "dark",
"language": "en",
"notifications": {
"email": True,
"push": False,
"frequency": "daily"
}
},
tags = ["premium", "beta-tester", "verified"],
metadata = {
"signup_source": "google",
"referral_code": "FRIEND50",
"last_login": "2024-06-19T10:00:00"
}
)
profile = UserProfile.get(id=uid)
# Access nested values
theme = profile["settings"]["theme"] # "dark"
email = profile["settings"]["notifications"]["email"] # True
first_tag = profile["tags"][0] # "premium"
# Update a nested value
settings = profile["settings"]
settings["theme"] = "light"
UserProfile.update({"settings": settings}, id=uid)
YugabyteDB JSONB advantage
YugabyteDB stores JSON as JSONB (a binary representation rather
than plain text), which is faster to query and lets you build
indexes that search inside the JSON itself.
ForeignKeyField
ForeignKeyField is how you link one table to another. Behind the
scenes it's just an INT column, but it's meant to hold the primary
key value of a row in a different table — the standard way relational
databases represent "this thing belongs to that thing." Pass the
related model's class name as a string to to=.
from mydborm import ForeignKeyField
class Author(BaseModel):
__tablename__ = "authors"
id = IntField(primary_key=True)
name = StrField(max_length=100, nullable=False)
class Book(BaseModel):
__tablename__ = "books"
id = IntField(primary_key=True)
title = StrField(max_length=200, nullable=False)
author_id = ForeignKeyField(to="Author", nullable=False)
category_id = ForeignKeyField(to="Category", nullable=True) # optional FK
price = FloatField(nullable=False)
SQL generated:
If you find yourself writing a lot of ForeignKeyField columns and
then joining across them by hand, take a look at
Relationships — it builds has_many/belongs_to
helpers on top of exactly this field type, so you don't have to write
the joins yourself.
Real-world example — full e-commerce schema:
class Category(BaseModel):
__tablename__ = "categories"
id = IntField(primary_key=True)
name = StrField(max_length=50, nullable=False)
parent_id = ForeignKeyField(to="Category", nullable=True) # self-referential
class Supplier(BaseModel):
__tablename__ = "suppliers"
id = IntField(primary_key=True)
name = StrField(max_length=100, nullable=False)
country = StrField(max_length=50, nullable=True)
class Product(BaseModel):
__tablename__ = "products"
id = IntField(primary_key=True)
name = StrField(max_length=100, nullable=False)
sku = StrField(max_length=20, nullable=False, unique=True)
category_id = ForeignKeyField(to="Category", nullable=True)
supplier_id = ForeignKeyField(to="Supplier", nullable=True)
price = FloatField(nullable=False)
# Create all tables
Category.create_table()
Supplier.create_table()
Product.create_table()
# Seed
cat_id = Category.create(name="Electronics")
sup_id = Supplier.create(name="TechCorp", country="USA")
prod_id = Product.create(
name = "Laptop Pro",
sku = "LAPTOP-001",
category_id = cat_id,
supplier_id = sup_id,
price = 999.99,
)
# JOIN query
results = (Product.query()
.inner_join("categories", "products.category_id = categories.id")
.inner_join("suppliers", "products.supplier_id = suppliers.id")
.where("categories.name", "Electronics")
.all())
Extended field types
The fields above cover most day-to-day needs, but mydborm also ships a larger set of more specialized field types, for when you need more control over exactly how something is stored — a smaller integer to save space, a fixed-width code, raw binary data, and so on. You don't need to know all of these up front; skim the headings and come back when you hit a specific need.
Integer variants
IntField is a good default, but if you know your numbers will always
be small (saving a little storage) or might be very large (avoiding
overflow errors), these variants give you more control:
TinyIntField— a 1-byte integer. MySQL stores it as a trueTINYINT(-128 to 127); YugabyteDB maps it up toSMALLINTsince it has no native tiny integer type. Good for small bounded values like a 1-5 star rating or a small priority level.SmallIntField— a 2-byte integer (-32768 to 32767) in both MySQL and YugabyteDB. Useful for things like a year or a sort order where you know the value will always be modest.BigIntField— an 8-byte integer, for when a regularIntFieldisn't big enough — file sizes in bytes, view counters, or IDs in a system large enough to exceed a few billion rows.UnsignedBigIntField— an 8-byte integer that can never be negative, doubling the usable positive range compared to a signedBigIntField. MySQL stores it asBIGINT UNSIGNED; YugabyteDB maps it toNUMERIC(20)to avoid overflow, since it has no unsigned integer type. Useful for checksums or token IDs that are always non-negative.
from mydborm import TinyIntField, SmallIntField, BigIntField, UnsignedBigIntField
class FileUpload(BaseModel):
__tablename__ = "file_uploads"
id = IntField(primary_key=True)
priority = TinyIntField(default=0) # small bounded value
sort_order = SmallIntField(default=0)
file_size = BigIntField(nullable=True) # bytes — can get large
checksum = UnsignedBigIntField(nullable=True) # always non-negative
Floating-point variants
DoubleField— a higher-precision floating point number thanFloatField(DOUBLEin MySQL,DOUBLE PRECISIONin YugabyteDB). Use it when you need more decimal digits of accuracy thanFloatFieldgives you — for example latitude/longitude coordinates, where small precision losses can shift a location noticeably.
from mydborm import DoubleField
class Location(BaseModel):
__tablename__ = "locations"
id = IntField(primary_key=True)
latitude = DoubleField(nullable=True)
longitude = DoubleField(nullable=True)
Fixed-width text and bits
CharField(length=n)— a fixed-width, space-padded string of exactlyncharacters (CHAR(n)). Use this instead ofStrFieldwhen every value really is the same length, like a 2-letter country code or a 3-letter currency code — it's slightly more efficient than a variable-length column for that case.BitField(length=n)— stores a fixed number of bits (1 to 64), for compact flag/permission storage.
from mydborm import CharField, BitField
class Address(BaseModel):
__tablename__ = "addresses"
id = IntField(primary_key=True)
country_code = CharField(length=2, nullable=False) # "US", "GB"
currency = CharField(length=3, nullable=True) # "USD", "EUR"
flags = BitField(length=8, nullable=True) # 8-bit flag set
Text variants
If TextField doesn't give you enough granularity, MySQL distinguishes
between a few sizes of unlimited text. YugabyteDB doesn't have separate
types for these, so mydborm maps all of them to a plain TEXT column
there:
TinyTextField— up to 255 bytes. Good for short captions or notes where you still want "no fixed length" semantics.MediumTextField— up to 16 MB. Good for long-form content like full articles.LongTextField— up to 4 GB. Good for very large content like whole documents or large log dumps.
from mydborm import TinyTextField, MediumTextField, LongTextField
class Article(BaseModel):
__tablename__ = "articles"
id = IntField(primary_key=True)
tagline = TinyTextField(nullable=True)
article_body = MediumTextField(nullable=False)
raw_html = LongTextField(nullable=True)
Binary data
These fields store raw bytes rather than text — use them for hashes,
encoded keys, or small files. MySQL has separate fixed- and
variable-length binary types; YugabyteDB stores all of them as
BYTEA.
BinaryField(length=n)— fixed-length binary data, e.g. exactly 32 bytes for a SHA-256 hash.VarBinaryField(max_length=n)— variable-length binary data up to a maximum size, e.g. a digital signature.BlobField(blob_type=...)— for genuinely large binary content like images, audio, or file attachments.blob_typecan be"TINYBLOB","BLOB"(the default),"MEDIUMBLOB", or"LONGBLOB", matching MySQL's size tiers; YugabyteDB stores all of them asBYTEAregardless of which tier you pick.
from mydborm import BinaryField, VarBinaryField, BlobField
class Document(BaseModel):
__tablename__ = "documents"
id = IntField(primary_key=True)
hash_value = BinaryField(length=32, nullable=True) # SHA-256
signature = VarBinaryField(max_length=256, nullable=True)
attachment = BlobField(blob_type="LONGBLOB", nullable=True)
TimeField and TimestampField
TimeField— stores a time of day with no date attached (e.g."09:00:00"), for things like opening hours.TimestampField— likeDateTimeField, but timezone-aware: MySQL stores it asTIMESTAMP, and YugabyteDB stores it asTIMESTAMPTZ. UseTimestampFieldfor columns likecreated_atorexpires_atwhere you need to know which timezone a moment happened in; use plainDateTimeFieldwhen timezone doesn't matter for your use case.
from mydborm import TimeField, TimestampField
class Store(BaseModel):
__tablename__ = "stores"
id = IntField(primary_key=True)
opens_at = TimeField(nullable=True)
closes_at = TimeField(nullable=True)
created_at = TimestampField(nullable=True)
expires_at = TimestampField(nullable=True)
EnumField and SetField
These two fields restrict a column to a fixed list of allowed values — the difference is whether a row can hold one value from the list or several at once:
EnumField(choices=[...])— exactly one value out of a fixed list, e.g. an order's status. MySQL uses its nativeENUM(...)type; YugabyteDB doesn't have a direct equivalent, so mydborm stores it as aVARCHARsized to fit the longest choice, and still validates that only allowed values are saved.SetField(choices=[...])— zero or more values from a fixed list, stored together in one column, e.g. a list of tags. MySQL uses its native comma-separatedSET(...)type; YugabyteDB stores it as a native array (TEXT[]). You can pass either a list/tuple/set of strings, or a single comma-separated string — both are accepted.
from mydborm import EnumField, SetField
class Order(BaseModel):
__tablename__ = "orders"
id = IntField(primary_key=True)
status = EnumField(choices=["pending", "processing", "shipped", "delivered"])
tags = SetField(choices=["gift", "fragile", "rush", "international"])
oid = Order.create(status="pending", tags=["gift", "rush"])
try:
Order.create(status="lost-in-space")
except ValueError as e:
print(e) # Field 'status' must be one of [...]. Got: 'lost-in-space'
Password and encrypted fields
For sensitive data, mydborm provides two purpose-built field types instead of expecting you to roll your own hashing or encryption:
PasswordField— automatically hashes whatever string you assign to it using bcrypt before it's saved. The hash is one-way: there's no way to recover the original password from it, which is exactly what you want for login credentials. You check a password later withPasswordField.verify(plain, hashed).EncryptedField— automatically encrypts the value using AES (via thecryptographylibrary's Fernet scheme) before saving, and can decrypt it back when you need the original value. Use this for things you need to retrieve later in their original form, like API keys or tokens — unlikePasswordField, this is two-way.
Both require the optional security extra:
from mydborm import BaseModel, IntField, StrField, PasswordField
class User(BaseModel):
__tablename__ = "users"
id = IntField(primary_key=True)
username = StrField(max_length=50, nullable=False)
password = PasswordField(nullable=False)
# Password is hashed automatically — you never see or store the raw value
uid = User.create(username="alice", password="mysecretpass")
user = User.get(id=uid)
print(PasswordField.verify("mysecretpass", user["password"])) # True
print(PasswordField.verify("wrongpass", user["password"])) # False
The full walkthrough — including EncryptedField, key generation, and
security best practices — lives on its own page: see
Security.
Custom validators
The nullable, unique, and max_length options cover the basics,
but sometimes you need a more specific rule — "must look like an
email address," "must be between 1 and 5," "must be one of these
exact strings." That's what the validators option is for: pass a
list of validator objects to any field, and mydborm runs them every
time you create() or update() a row, before anything is sent to
the database.
from mydborm import StrField, EmailValidator
class Contact(BaseModel):
__tablename__ = "contacts"
id = IntField(primary_key=True)
email = StrField(max_length=255, nullable=False,
validators=[EmailValidator()])
Contact.create(email="alice@example.com") # OK
try:
Contact.create(email="not-an-email")
except ValueError as e:
print(e)
# Field 'email' must be a valid email address. Got: 'not-an-email'
mydborm ships six built-in validators covering the most common cases — email format, URL format, regex patterns, numeric ranges, minimum string length, and fixed choice lists — and you can attach more than one to the same field, or write your own. The full reference, with examples for each one, lives on its own page: see Validators.
You can also validate across multiple fields at once (for example,
"if country is US, state is required") using a model's __validators__
list:
class ShippingAddress(BaseModel):
__tablename__ = "shipping_addresses"
id = IntField(primary_key=True)
country = StrField(max_length=2, nullable=False)
postal_code = StrField(max_length=10, nullable=False)
state = StrField(max_length=50, nullable=True)
__validators__ = [
# US addresses require state
lambda data: (_ for _ in ()).throw(
ValueError("State is required for US addresses")
) if data.get("country") == "US" and not data.get("state") else None,
# US zip codes: 5 digits or 5+4
lambda data: (_ for _ in ()).throw(
ValueError("Invalid US zip code format")
) if (data.get("country") == "US" and
not __import__("re").match(r"^\d{5}(-\d{4})?$",
data.get("postal_code", ""))) else None,
]
# OK
ShippingAddress.create(country="US", postal_code="10001", state="NY")
ShippingAddress.create(country="GB", postal_code="SW1A 1AA")
# Fails — US needs state
try:
ShippingAddress.create(country="US", postal_code="10001")
except ValueError as e:
print(e) # State is required for US addresses
Where to go next
- Models & CRUD — using fields together in a model, and the full create/read/update/delete API
- Validators — the complete validator reference
- Security —
PasswordFieldandEncryptedFieldin depth, including key management - Migrations — changing field definitions on a table that already has data in it