Skip to content

Field Types

Fullfinity provides a rich set of field types for modeling your data.

Fields are generic typing descriptors, so editors and pyright resolve record.<field> to its real Python type instead of Any — both inside the model’s own methods (self.name) and on references elsewhere:

class Product(Model):
name = Char(max_length=255) # record.name → str
price = Float() # record.price → float
active = Boolean() # record.active → bool
async def label(self) -> str:
return self.name.upper() # type-checked: self.name is str

Scalar fields map to their obvious types (Char/Text/Selectionstr, Integerint, Float/Monetaryfloat, Booleanbool, Datedate, Datetimedatetime, Binarybytes); JSON/File and relational fields (ManyToOne, OneToMany, …) resolve to Any (relations are awaited/lazy). This is typing-only — at runtime Model.__getattribute__ serves values from the record’s data, so the descriptors add no behavior. Class-level access (Product.name) still returns the Field instance for introspection.

Run the checker with the project’s pyrightconfig.json.

All fields support these options:

OptionTypeDefaultDescription
requiredboolFalseNOT NULL constraint
defaultanyNoneDefault value (can be callable)
descriptionstrNoneHuman-readable label
hintstrNoneHelp text/tooltip
readonlyboolFalseField cannot be edited
indexboolFalseCreate database index
uniqueboolFalseEnforce uniqueness at database level
cloneboolTrueInclude field value when duplicating a record
storeboolTrueStore in database
calculatestrNoneCompute method name
setterstrNoneMethod to call when setting a calculated field value
calculate_elevatedboolFalseBypass access rights during calculation
company_scopedboolFalseStore value per-company in CompanyConfig
groupslist[str][]Gate read/write of this field by group at the API boundary (see below)
validatorslist[]List of validation functions (see Validators)
prefix_fieldstrNoneDynamic field path for prefix display (e.g., "currency__symbol")
suffix_fieldstrNoneDynamic field path for suffix display (e.g., "uom__name")

groups restricts who can read or write a single field to members of the listed groups:

cost_price = Float(groups=["inventory_manager"]) # only managers see/edit it
  • Read — the serializer omits the field from API output for users not in any listed group. They never see the value (the column/data is untouched).
  • Write — the create/update endpoints raise AccessError if such a user tries to set the field.
  • Admin bypass — members of core_admin always have access.
  • Boundary-only — this is enforced at the API serializer and create/update endpoints, not in-process. Direct ORM access inside server code is intentionally ungated, so your own model logic can always read/write the field.

Single-line text with max length:

name = Char(max_length=255, required=True)
email = Char(max_length=200, index=True)
sku = Char(max_length=50, description="Stock Keeping Unit")
# With validation
phone = Char(max_length=20, min_length=10)
code = Char(max_length=10, regex=r"^[A-Z]{2}-\d{4}$")
PropertyTypeDescription
max_lengthintMaximum character length (required)
min_lengthintMinimum character length
regexstrRegular expression pattern for validation

Unlimited multi-line text:

description = Text()
notes = Text(description="Internal Notes")

A credential field that is encrypted at rest. The value is plaintext in Python but stored as Fernet ciphertext in the column (keyed off the configured SECRET_KEY). Use it for API keys, OAuth tokens, and other secrets.

api_secret = Encrypted(description="API Secret")
access_token = Encrypted(description="Access Token")
  • Reads/writes are transparent — assign and read the field as a normal string; encryption/decryption happen in to_db_value/from_db_value.
  • Backed by VARCHAR (sized for ciphertext via max_length, default 2048), so converting an existing Char secret to Encrypted is a safe in-place column widening — no data migration needed.
  • Legacy un-encrypted values (stored before conversion) are detected by the absence of the enc:v1: prefix and returned unchanged on read, then encrypted on the next write.
  • Filtering by exact value won’t work (the column holds ciphertext); isnotnull and similar presence checks do.

:::caution Key rotation makes existing values unreadable The Fernet key is derived deterministically from SECRET_KEY. Rotating SECRET_KEY makes all previously-stored ciphertext undecryptable. On a decrypt failure the field does not raise — it returns the stored token unchanged (the prefix-stripped ciphertext), so reads never crash and no data is lost, but the plaintext is gone until you restore the original key. If SECRET_KEY is unset entirely, reading/writing an Encrypted field raises ConfigurationError. Plan a re-encryption step if you ever rotate the key. :::

Whole numbers:

quantity = Integer(default=1)
sequence = Integer(default=10)
age = Integer(required=True, min_value=0, max_value=150)
PropertyTypeDescription
min_valueintMinimum allowed value
max_valueintMaximum allowed value

Floating-point numbers with configurable precision:

# Static precision (2 decimal places)
weight = Float(precision=2, default=0.0)
percentage = Float(precision=4, min_value=0, max_value=100)
# Dynamic precision from field on same model
rate = Float(precision="decimal_places")
# Dynamic precision from related model (one level only)
quantity = Float(precision="uom__rounding")
PropertyTypeDescription
precisionint/strDecimal places: static int (default: 2) or field path string
min_valuefloatMinimum allowed value
max_valuefloatMaximum allowed value

:::info Default Value Float fields default to 0.0 if no default is specified. :::

Currency values with precision from currency field:

# Precision from currency.rounding (recommended)
price = Monetary(currency_field="currency")
# Without currency field - defaults to 2 decimal places
discount = Monetary(min_value=0, max_value=10000)
PropertyTypeDescription
currency_fieldstrField name containing the currency (precision from currency.rounding)
min_valuefloatMinimum allowed value
max_valuefloatMaximum allowed value

:::info Default Value Monetary fields default to 0.0 if no default is specified. :::

:::tip Rounding Utilities For precise float/monetary rounding in calculations, use the engine utilities:

from fullfinity.engine.utils import round_float, is_zero, compare
# Round to 2 decimal places
amount = round_float(100.125, digits=2) # 100.13
# Round using currency rounding factor
amount = round_float(100.125, rounding=0.01) # 100.13
# Check if effectively zero
if is_zero(0.001, rounding=0.01): # True
pass
# Compare floats safely
if compare(a, b, rounding=0.01) == 0: # Equal after rounding
pass

:::

:::tip Currency on Line Items For line items where currency is on the parent model, use a related field:

class OrderLine(Model):
order = ManyToOne("Order")
currency = ManyToOne("Currency", related_field="order__currency", store=False)
unit_price = Monetary(currency_field="currency")

:::

Float fields support dynamic precision using field path strings:

SyntaxDescriptionExample
precision=4Static integerFixed 4 decimal places
precision="rounding"Same model fieldUses self.rounding
precision="uom__rounding"Related model fieldUses self.uom.rounding

:::warning No Deep Nesting Deep paths like company__currency__rounding are not allowed. Only one level of relation traversal is supported. :::

Any field can display a dynamic prefix or suffix by defining field paths on the model:

# Display UOM name as suffix (e.g., "10 kg")
qty_on_hand = Float(
precision=3,
suffix_field="uom__name"
)
# Display currency symbol as prefix (e.g., "$ 100")
amount = Float(
prefix_field="currency__symbol"
)
PropertyTypeDescription
prefix_fieldstrField path for dynamic prefix (e.g., "uom__symbol", "currency__symbol")
suffix_fieldstrField path for dynamic suffix (e.g., "uom__name")

The referenced field is automatically extracted and serialized with the record. The frontend displays the value from the resolved field path.

:::tip Model vs View

  • Model: Use prefix_field/suffix_field for dynamic values from related fields
  • View: Use prefix/suffix for static strings only (e.g., "%", "kg")

Model’s dynamic fields take priority over view’s static values. :::

:::info Monetary Fields For currency values, use Monetary with currency_field instead. It automatically handles symbol positioning (before/after) based on currency settings. :::

True/False values:

active = Boolean(default=True)
is_published = Boolean(default=False)

Date without time:

birth_date = Date()
due_date = Date(description="Due Date")

Date with time (stored in UTC):

created_at = Datetime(default=lambda: datetime.now())
scheduled_at = Datetime()

Predefined choices:

status = Selection(
choices=["Draft", "Confirmed", "Done", "Cancelled"],
default="Draft",
max_length=200
)
priority = Selection(
choices=["Low", "Medium", "High", "Urgent"],
default="Medium"
)
type = Selection(
choices=["Individual", "Company"],
required=True
)
PropertyTypeDescription
choiceslistList of allowed string values (required)
max_lengthintMaximum length for values (default: 200)

:::tip Extending Choices Extensions can add or remove Selection choices via _selection_add and _selection_remove. See Selection Field Inheritance. :::

Store JSON/dict data:

metadata = JSON(default=dict)
settings = JSON(description="User Settings")
extra_data = JSON()

Usage:

record.metadata = {"key": "value", "nested": {"a": 1}}
await record.save()

Raw binary data:

data = Binary(description="Binary Data")

File attachments (stored as attachment ID):

logo = File(description="Company Logo")
document = File(description="Attached Document")
public_brochure = File(public=True, description="Brochure") # publicly accessible

Usage:

# Upload: base64 string with format: "filename;content_type;base64,data"
record.logo = "logo.png;image/png;base64,iVBORw0KGgo..."
await record.save()
OptionTypeDefaultDescription
publicboolFalseWhen True, the underlying attachment is created with is_public=True, so it is reachable without an authenticated session (e.g. logos, website assets). Leave False for anything access-controlled.
trackboolFalseRecord changes to this file in the change log.

Foreign key (many records point to one):

customer = ManyToOne(
"Contact", # Related model name
related_name="orders", # Reverse relation name
on_delete="CASCADE", # CASCADE, SET NULL, RESTRICT
required=True
)
category = ManyToOne(
"Category",
related_name="products",
on_delete="SET NULL"
)

Reverse of ManyToOne (automatically created):

# Defined on the "one" side, points to "many"
orders = OneToMany("Order", related_name="customer")

Usually you don’t define this explicitly - it’s created from related_name.

One-to-one relationship (foreign key with UNIQUE constraint):

profile = OneToOne(
"UserProfile",
related_name="user", # Reverse returns single object, not list
on_delete="CASCADE"
)
settings = OneToOne(
"UserSettings",
related_name="user",
on_delete="SET NULL"
)

Similar to ManyToOne but:

  • Has a UNIQUE constraint on the foreign key column
  • The reverse accessor returns a single object instead of a list

Many-to-many relationship:

tags = ManyToMany(
"Tag",
related_name="products",
through="fkproducttag" # Through table name
)
groups = ManyToMany(
"Group",
related_name="users",
through="fkusergroup"
)

:::note Relational defaults ManyToMany and OneToMany also support default=, but the value must be written in command notation ([["L", id]], [["C", {vals}]]), not a bare list of instances. See Relational field defaults. :::

Fields calculated from other fields:

total = Monetary(
calculate="_compute_total",
store=True # Store result in database
)
display_name = Char(
max_length=255,
calculate="get_display_name",
store=False # Calculate on-the-fly
)

See Computed Fields for details.

Auto-compute from related model:

company_name = Char(
max_length=255,
related_field="company__name",
store=False
)
country_code = Char(
max_length=10,
related_field="address__country__code",
store=False
)
class Contact(Model):
# Identity
name = Char(max_length=255, required=True)
email = Char(max_length=255, index=True)
phone = Char(max_length=50)
# Type
type = Selection(choices=["Individual", "Company"], default="Individual")
is_customer = Boolean(default=False)
is_supplier = Boolean(default=False)
# Relations
company = ManyToOne("Contact", related_name="contacts")
tags = ManyToMany("ContactTag", related_name="contacts", through="fkcontacttag")
# Media
image = File(description="Photo")
# Timestamps
created_at = Datetime(default=lambda: datetime.now())
class Invoice(Model):
# Reference
name = Char(max_length=100, required=True)
reference = Char(max_length=100)
# Dates
invoice_date = Date(default=lambda: datetime.now().date())
due_date = Date()
# Relations
customer = ManyToOne("Contact", required=True)
currency = ManyToOne("Currency")
# Status
status = Selection(
choices=["Draft", "Sent", "Paid", "Cancelled"],
default="Draft"
)
# Financials
subtotal = Monetary(calculate="_compute_totals", store=True)
tax_total = Monetary(calculate="_compute_totals", store=True)
total = Monetary(calculate="_compute_totals", store=True)
# Notes
notes = Text()
internal_notes = Text(description="Internal Notes")

Fields with company_scoped=True store their values in the CompanyConfig table instead of the model’s own table. This enables per-company configuration for the same record.

class ProductCategory(Model):
name = Char(max_length=255)
# Different income account per company
income_account = ManyToOne(
"Account",
description="Income Account",
company_scoped=True # Stored in CompanyConfig
)
# Different expense account per company
expense_account = ManyToOne(
"Account",
description="Expense Account",
company_scoped=True
)
  • Storage: Values are stored in CompanyConfig with key format: Model.record_id.field_name
  • Transient Models: For transient models (wizards/configuration), key is just field_name
  • Hydration: ManyToOne/ManyToMany fields return fully hydrated records, not just IDs
  • Orphan Cleanup: Orphaned references are automatically cleaned up on load
  • Migrations: Fields are excluded from database migrations (store=False implied)
  • Scalar types (Char, Text, Boolean, Integer, Float, etc.)
  • ManyToOne (stores single ID)
  • ManyToMany (stores list of IDs)
  • Cannot filter on company_scoped fields: filter(income_account=x) won’t work
  • Cannot sort on company_scoped fields
  • No database-level uniqueness constraints
class ConfigurationCRM(Model):
__inherit__ = "Configuration"
crm_default_stage = ManyToOne(
"CrmStage",
description="Default Lead Stage",
company_scoped=True
)
crm_auto_assign = Boolean(
default=False,
description="Auto-assign Leads",
company_scoped=True
)

Fields support custom validators for advanced validation logic. Pass a list of validators to the validators parameter.

from fullfinity.engine.fields import (
EmailValidator,
URLValidator,
RegexValidator,
MinValueValidator,
MaxValueValidator,
MinLengthValidator,
MaxLengthValidator,
)
class Contact(Model):
email = Char(
max_length=255,
validators=[EmailValidator("Please enter a valid email")]
)
website = Char(
max_length=255,
validators=[URLValidator("Please enter a valid URL")]
)
code = Char(
max_length=20,
validators=[
RegexValidator(r"^[A-Z]{3}-\d{4}$", "Code must be XXX-0000 format")
]
)
age = Integer(
validators=[
MinValueValidator(0, "Age cannot be negative"),
MaxValueValidator(150, "Age cannot exceed 150")
]
)
username = Char(
max_length=50,
validators=[
MinLengthValidator(3, "Username must be at least 3 characters"),
MaxLengthValidator(50, "Username cannot exceed 50 characters")
]
)
ValidatorDescription
EmailValidator(message)Validates email format
URLValidator(message)Validates URL format (http/https)
RegexValidator(pattern, message)Validates against regex pattern
MinValueValidator(min, message)Validates minimum numeric value
MaxValueValidator(max, message)Validates maximum numeric value
MinLengthValidator(min, message)Validates minimum string length
MaxLengthValidator(max, message)Validates maximum string length

Create custom validators by subclassing Validator or using a callable:

from fullfinity.engine.fields import Validator, ValidationError
# Class-based validator
class PhoneValidator(Validator):
def __init__(self, message="Invalid phone number"):
self.message = message
def __call__(self, value):
if value and not value.replace("-", "").replace(" ", "").isdigit():
raise ValidationError(self.message)
# Function-based validator
def validate_positive(value):
if value is not None and value < 0:
raise ValidationError("Value must be positive")
class Order(Model):
phone = Char(max_length=20, validators=[PhoneValidator()])
quantity = Integer(validators=[validate_positive])