Fields¶
Field types define how data is stored in PostgreSQL and validated in Python.
Overview¶
Every field in Aksara maps to a PostgreSQL column type and provides:
- Type validation — Ensures correct Python types
- Database mapping — Converts to/from PostgreSQL types
- AI metadata — Describes the field to LLMs
- Constraints — Unique, nullable, default values
Common Field Options¶
All fields support these options:
| Option | Type | Default | Description |
|---|---|---|---|
nullable |
bool |
False |
Allow NULL values |
default |
Any |
None |
Default value |
unique |
bool |
False |
Enforce uniqueness |
primary_key |
bool |
False |
Mark as primary key |
db_index |
bool |
False |
Create database index |
ai_description |
str |
"" |
Human-readable field purpose used in AI context and tool exports |
ai_sensitive |
bool |
False |
Hide the field from AI context, Studio AI features, and MCP exports |
ai_agent_writable |
bool |
True |
Whether AI-driven write paths are allowed to modify the field |
Example:
class User(Model):
email = fields.Email(
unique=True,
nullable=False,
ai_description="User's login email address",
ai_sensitive=False,
)
Text Fields¶
String¶
Variable-length text with a maximum length.
name = fields.String(max_length=100)
code = fields.String(max_length=10, unique=True)
nickname = fields.String(max_length=50, nullable=True)
# With validation parameters
severity = fields.String(
max_length=20,
choices=["low", "medium", "high", "critical"],
default="medium",
ai_description="Impact level for triage priority",
)
password = fields.String(max_length=128, min_length=8)
phone = fields.String(max_length=20, regex=r"^\+?[\d\s\-]{7,15}$")
clean_name = fields.String(max_length=100, strip_whitespace=True)
| Option | Type | Default | Description |
|---|---|---|---|
max_length |
int |
255 |
Maximum character length |
min_length |
int |
None |
Minimum character length (validated on save) |
choices |
list |
None |
Restrict to these values — flat list or [(value, label)] pairs |
regex |
str |
None |
Regex pattern the value must fully match |
strip_whitespace |
bool |
False |
Strip leading/trailing whitespace on save |
PostgreSQL type: VARCHAR(max_length)
Validation Order
When multiple validation parameters are set, they run in this order:
strip_whitespace → min_length → choices → regex
Text¶
Unlimited length text for large content.
content = fields.Text()
description = fields.Text(nullable=True)
notes = fields.Text(default="")
bio = fields.Text(min_length=20, strip_whitespace=True)
| Option | Type | Default | Description |
|---|---|---|---|
max_length |
int |
None |
Optional max length (validated, DB stays TEXT) |
min_length |
int |
None |
Minimum character length (validated on save) |
strip_whitespace |
bool |
False |
Strip leading/trailing whitespace on save |
PostgreSQL type: TEXT
When to Use Text vs String
- Use
Stringfor short, bounded content (names, codes, slugs) - Use
Textfor long content (articles, descriptions, JSON strings)
Email¶
Email addresses with format validation.
PostgreSQL type: VARCHAR(254) (maximum email length per RFC)
Validation: Must match email format pattern.
URL¶
URLs with format validation.
PostgreSQL type: TEXT
Validation: Must be a valid HTTP/HTTPS URL.
Media Fields¶
FileField¶
Store files through Aksara's media storage abstraction.
class Document(Model):
title = fields.String(max_length=200)
attachment = fields.FileField(upload_to="documents")
PostgreSQL type: VARCHAR(500) by default.
When you access the field on a model instance, Aksara returns a FieldFile
wrapper instead of a raw string:
document = await Document.objects.get(id=doc_id)
document.attachment.url # "/media/documents/..." or S3 URL
document.attachment.path # Local filesystem path when available
await document.attachment.exists()
await document.attachment.size()
await document.attachment.read()
You can assign either an existing stored path string or an upload-like value:
ImageField¶
Image-specialized file field with Pillow-backed validation.
ImageField accepts the same inputs as FileField, but verifies that the
uploaded content is a real image before saving it.
Custom upload endpoints
ModelViewSet schemas still expose file and image fields as strings.
For browser uploads, add a custom FastAPI endpoint that accepts
UploadFile, then assign that object to the model field and call
await instance.save().
See Advanced Media & Email for storage configuration, S3 usage, and upload endpoint examples.
Numeric Fields¶
Integer¶
Standard 32-bit integer.
age = fields.Integer()
quantity = fields.Integer(default=0)
position = fields.Integer(nullable=True)
# With validation parameters
rating = fields.Integer(min_value=1, max_value=5)
priority = fields.Integer(choices=[1, 2, 3, 4, 5], default=3)
| Option | Type | Default | Description |
|---|---|---|---|
min_value |
int |
None |
Minimum allowed value |
max_value |
int |
None |
Maximum allowed value |
choices |
list |
None |
Restrict to these values |
PostgreSQL type: INTEGER
Range: -2,147,483,648 to 2,147,483,647
BigInteger¶
64-bit integer for large numbers.
PostgreSQL type: BIGINT
Range: -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807
Validation: Values outside the 64-bit signed integer range are rejected at the Python level before reaching the database.
SmallInteger¶
16-bit integer for compact storage.
priority = fields.SmallInteger(default=0)
day_of_week = fields.SmallInteger(choices=[0, 1, 2, 3, 4, 5, 6])
| Option | Type | Default | Description |
|---|---|---|---|
choices |
list |
None |
Restrict to these values |
PostgreSQL type: SMALLINT
Range: -32,768 to 32,767
Positive Integer Variants¶
Integer fields that reject negative values at the Python level.
view_count = fields.PositiveInteger(default=0) # INTEGER, >= 0
retry_count = fields.PositiveSmallInteger(default=0) # SMALLINT, 0..32767
big_counter = fields.PositiveBigInteger(default=0) # BIGINT, >= 0
| Field | PostgreSQL Type | Range |
|---|---|---|
PositiveInteger |
INTEGER |
0 to 2,147,483,647 |
PositiveSmallInteger |
SMALLINT |
0 to 32,767 |
PositiveBigInteger |
BIGINT |
0 to 9,223,372,036,854,775,807 |
Float¶
Floating-point number.
PostgreSQL type: DOUBLE PRECISION
| Option | Type | Default | Description |
|---|---|---|---|
min_value |
float |
None |
Minimum allowed value |
max_value |
float |
None |
Maximum allowed value |
Precision
Float is not suitable for monetary values due to precision issues. Use Decimal instead.
Decimal¶
Exact decimal for financial data.
price = fields.Decimal(precision=10, scale=2)
tax_rate = fields.Decimal(precision=5, scale=4)
percentage = fields.Decimal(precision=5, scale=2, min_value=0, max_value=100)
| Option | Type | Default | Description |
|---|---|---|---|
precision |
int |
10 |
Total digits |
scale |
int |
2 |
Digits after decimal |
min_value |
float |
None |
Minimum allowed value |
max_value |
float |
None |
Maximum allowed value |
PostgreSQL type: NUMERIC(precision, scale)
Example values with precision=10, scale=2:
- Valid: 12345678.90, 0.01, -999.99
- Invalid: 123456789.00 (too many digits)
Boolean Field¶
Boolean¶
True/False values.
is_active = fields.Boolean(default=True)
is_verified = fields.Boolean(default=False)
newsletter_opted_in = fields.Boolean(nullable=True) # True, False, or unknown
PostgreSQL type: BOOLEAN
Date and Time Fields¶
DateTime¶
Timestamp with timezone.
created_at = fields.DateTime(auto_now_add=True)
updated_at = fields.DateTime(auto_now=True)
scheduled_at = fields.DateTime(nullable=True)
| Option | Type | Default | Description |
|---|---|---|---|
auto_now_add |
bool |
False |
Set on creation only |
auto_now |
bool |
False |
Set on every save |
PostgreSQL type: TIMESTAMP WITH TIME ZONE
Date¶
Date without time.
PostgreSQL type: DATE
Time¶
Time of day without a date component.
PostgreSQL type: TIME
Accepts datetime.time objects, datetime.datetime (extracts the time part), and ISO 8601 time strings including fractional seconds:
from datetime import time
class Schedule(Model):
alarm = fields.Time()
schedule = await Schedule.objects.create(alarm=time(7, 30, 0))
# Also accepts: alarm="07:30:00" or alarm="07:30:00.123456"
Duration¶
Time spans stored as PostgreSQL intervals.
PostgreSQL type: INTERVAL
Accepts datetime.timedelta objects, or numeric values interpreted as seconds:
from datetime import timedelta
class Recipe(Model):
prep_time = fields.Duration()
recipe = await Recipe.objects.create(prep_time=timedelta(minutes=30))
# Also accepts: prep_time=1800 (seconds)
Special Fields¶
UUID¶
Universally unique identifier.
PostgreSQL type: UUID
Primary Key
The model's id field is automatically a UUID primary key. You don't need to define it.
JSON¶
JSON/JSONB data.
metadata = fields.JSON(default=dict)
settings = fields.JSON(default=list)
config = fields.JSON(nullable=True)
PostgreSQL type: JSONB (binary JSON for efficient querying)
Example usage:
class User(Model):
preferences = fields.JSON(default=dict)
user = await User.objects.create(
preferences={"theme": "dark", "notifications": True}
)
Nested JSON keys can be queried with double-underscore paths:
Vector¶
pgvector-backed embeddings for similarity search and ranking.
class Document(Model):
title = fields.String(max_length=200)
embedding = fields.Vector(dimensions=384)
PostgreSQL type: VECTOR(n) when dimensions are specified, otherwise VECTOR
Vector values are stored as Python lists and serialized to pgvector literals
for inserts, updates, and filters.
document = await Document.objects.create(
title="Intro",
embedding=[0.12, 0.33, 0.98],
)
assert document.embedding == [0.12, 0.33, 0.98]
Note
Vector requires PostgreSQL's vector extension. Enable it with
CREATE EXTENSION IF NOT EXISTS vector before creating tables that use the field.
Enum¶
Enumerated values.
from enum import Enum
class Status(str, Enum):
DRAFT = "draft"
PUBLISHED = "published"
ARCHIVED = "archived"
class Article(Model):
status = fields.Enum(enum_class=Status, default=Status.DRAFT)
| Option | Type | Required | Description |
|---|---|---|---|
enum_class |
type[Enum] |
Yes | The enum class |
PostgreSQL type: VARCHAR (stores the string value)
Array¶
PostgreSQL array columns for storing lists of values.
tags = fields.Array(base_type="text", default=list)
scores = fields.Array(base_type="integer", nullable=True)
ratings = fields.Array(base_type="float")
| Option | Type | Default | Description |
|---|---|---|---|
base_type |
str |
"text" |
Element type: text, integer, float, boolean, uuid |
PostgreSQL types:
base_type |
PostgreSQL Type |
|---|---|
text |
TEXT[] |
integer |
INTEGER[] |
float |
DOUBLE PRECISION[] |
boolean |
BOOLEAN[] |
uuid |
UUID[] |
Example usage:
class Article(Model):
tags = fields.Array(base_type="text", default=list)
view_counts = fields.Array(base_type="integer", default=list)
article = await Article.objects.create(
tags=["python", "async", "orm"],
view_counts=[100, 250, 180],
)
# Access as Python lists
print(article.tags) # ['python', 'async', 'orm']
article.tags.append("database")
await article.save()
When to Use Array vs JSON
- Use
Arrayfor homogeneous lists (all same type) that need indexing - Use
JSONfor heterogeneous data or nested structures - PostgreSQL array operators work with
Arrayfields
Slug¶
URL-safe slugs with pattern validation and optional auto-generation.
slug = fields.Slug(max_length=100, unique=True)
# Auto-generate from another field
slug = fields.Slug(max_length=200, auto_from="title", unique=True)
| Option | Type | Default | Description |
|---|---|---|---|
max_length |
int |
50 |
Maximum character length |
allow_unicode |
bool |
False |
Allow non-ASCII characters (e.g., héllo-wörld) |
auto_from |
str |
None |
Auto-generate slug from this field when slug is empty |
db_index |
bool |
False |
Create database index |
PostgreSQL type: VARCHAR(max_length)
Validation: Only letters, numbers, hyphens, and underscores are accepted. When allow_unicode=False (the default), only ASCII characters are valid.
class Article(Model):
title = fields.String(max_length=200)
slug = fields.Slug(max_length=200, auto_from="title", unique=True)
# When slug is empty/None on save, it's auto-generated from title:
# "Hello World Article" → "hello-world-article"
# Existing slug values are preserved (no overwrite on update)
IPAddress / GenericIPAddress¶
IP addresses with protocol validation.
server_ip = fields.IPAddress()
client_ip = fields.IPAddress(protocol="ipv4")
gateway = fields.IPAddress(protocol="ipv6")
| Option | Type | Default | Description |
|---|---|---|---|
protocol |
str |
"both" |
Restrict to "ipv4", "ipv6", or "both" |
unpack_ipv4 |
bool |
False |
Convert IPv4-mapped IPv6 (e.g., ::ffff:192.0.2.1) to plain IPv4 |
PostgreSQL type: INET
GenericIPAddressField is an alias for IPAddressField.
Binary¶
Raw binary data.
PostgreSQL type: BYTEA
Accepts bytes, bytearray, memoryview, and strings (encoded as UTF-8).
AI Metadata Defaults
BinaryField defaults to ai_sensitive=True and ai_agent_writable=False to prevent AI agents from reading or modifying raw binary data.
FilePath¶
File system paths with dynamic choice generation.
template = fields.FilePath(
path="/app/templates",
match=r".*\.html$",
recursive=True,
max_length=200,
)
| Option | Type | Default | Description |
|---|---|---|---|
path |
str |
"" |
Root directory to scan |
match |
str |
None |
Regex pattern to filter file names |
recursive |
bool |
False |
Scan subdirectories |
allow_files |
bool |
True |
Include files in choices |
allow_folders |
bool |
False |
Include directories in choices |
max_length |
int |
100 |
Maximum path length |
PostgreSQL type: VARCHAR(max_length)
The choices() method returns a list of (path, display_name) tuples based on the configured directory scan.
Relationship Fields¶
ForeignKey¶
Many-to-one relationship.
from aksara import fields, CASCADE
class Post(Model):
author = fields.ForeignKey(
"User",
on_delete=CASCADE,
related_name="posts",
)
| Option | Type | Default | Description |
|---|---|---|---|
to |
str or type |
Required | Target model |
on_delete |
str |
CASCADE |
Delete behavior |
related_name |
str |
Auto | Reverse accessor name |
nullable |
bool |
False |
Allow NULL |
See Relations for detailed documentation.
ManyToMany¶
Many-to-many relationship.
| Option | Type | Default | Description |
|---|---|---|---|
to |
str or type |
Required | Target model |
related_name |
str |
Auto | Reverse accessor name |
through |
str |
Auto | Junction table name |
OneToOne¶
One-to-one relationship.
class UserProfile(Model):
user = fields.OneToOne(
"User",
on_delete=CASCADE,
related_name="profile",
)
Similar to ForeignKey but enforces uniqueness.
on_delete Options¶
When a referenced object is deleted:
| Value | Behavior |
|---|---|
CASCADE |
Delete this object too |
SET_NULL |
Set the FK to NULL (requires nullable=True) |
RESTRICT |
Prevent deletion if references exist |
PROTECT |
Alias for RESTRICT |
from aksara import fields, CASCADE, SET_NULL, RESTRICT
class Post(Model):
# Delete posts when author is deleted
author = fields.ForeignKey(User, on_delete=CASCADE)
# Set to NULL when category is deleted
category = fields.ForeignKey(Category, on_delete=SET_NULL, nullable=True)
# Prevent deletion if posts reference this tag
primary_tag = fields.ForeignKey(Tag, on_delete=RESTRICT)
AI Metadata¶
Every field supports AI metadata:
class User(Model):
email = fields.Email(
unique=True,
ai_description="User's email address for login and notifications",
ai_sensitive=False,
ai_agent_writable=True,
)
hashed_password = fields.String(
ai_description="Bcrypt-hashed password (never expose raw)",
ai_sensitive=True, # Hidden from AI context exports
ai_agent_writable=False, # AI cannot modify this field
)
role = fields.String(
max_length=20,
default="user",
ai_description="User role: 'user', 'admin', or 'moderator'",
ai_agent_writable=True, # AI can change roles
)
| Option | Type | Default | Description |
|---|---|---|---|
ai_description |
str |
"" |
Human-readable field purpose used in AI context and tool exports |
ai_sensitive |
bool |
False |
Hide the field from AI context, Studio AI features, and MCP exports |
ai_agent_writable |
bool |
True |
Whether AI-driven write paths are allowed to modify the field |
AI Metadata and Guardrails¶
These three options control how Aksara presents your schema to AI features.
ai_description gives the field intent instead of just a type name. That description is reused in Studio AI Console prompts, exported tool schemas, and other AI-facing context builders, so it is worth writing as if another developer has to understand the field without opening the model.
ai_sensitive=True removes a field from AI-facing exports. Use it for hashed passwords, secret tokens, internal identifiers, or any value that should never appear in generated context for external agents.
ai_agent_writable=False keeps a field visible while blocking agent-initiated updates. That is the right choice for computed totals, audit fields, approval states, or any value a human or a trusted backend process owns.
class Customer(Model):
email = fields.Email(
ai_description="Primary contact email for the customer"
)
stripe_customer_id = fields.String(
ai_sensitive=True,
ai_description="Internal billing identifier"
)
lifetime_value = fields.Decimal(
precision=10,
scale=2,
ai_description="Computed revenue total in USD",
ai_agent_writable=False,
)
Field Validation¶
Fields validate data automatically:
from aksara.exceptions import ValidationError
class User(Model):
email = fields.Email()
age = fields.Integer()
# Invalid email format
try:
user = User(email="not-an-email", age=25)
await user.save()
except ValidationError as e:
print(e) # "Invalid email format"
# Invalid integer
try:
user = User(email="test@example.com", age="twenty")
await user.save()
except ValidationError as e:
print(e) # "Expected integer"
Complete Example¶
from aksara import Model, fields, CASCADE
from enum import Enum
from decimal import Decimal
class ProductStatus(str, Enum):
DRAFT = "draft"
ACTIVE = "active"
DISCONTINUED = "discontinued"
class Product(Model):
"""E-commerce product model."""
# Basic info
name = fields.String(
max_length=200,
ai_description="Product display name",
)
slug = fields.Slug(
max_length=200,
unique=True,
ai_description="URL-friendly identifier",
)
description = fields.Text(
nullable=True,
ai_description="Full product description",
)
# Pricing
price = fields.Decimal(
precision=10,
scale=2,
ai_description="Current price in USD",
)
compare_at_price = fields.Decimal(
precision=10,
scale=2,
nullable=True,
ai_description="Original price for showing discounts",
)
# Inventory
sku = fields.String(
max_length=50,
unique=True,
ai_description="Stock keeping unit",
)
quantity = fields.Integer(
default=0,
ai_description="Available inventory count",
)
# Status
status = fields.Enum(
enum_class=ProductStatus,
default=ProductStatus.DRAFT,
ai_description="Product visibility status",
)
# Relations
category = fields.ForeignKey(
"Category",
on_delete=SET_NULL,
nullable=True,
related_name="products",
)
# Metadata
metadata = fields.JSON(
default=dict,
ai_description="Additional product attributes",
)
# Timestamps
created_at = fields.DateTime(auto_now_add=True)
updated_at = fields.DateTime(auto_now=True)
Best Practices¶
Choose Appropriate Types¶
# Good
price = fields.Decimal(precision=10, scale=2) # Exact for money
rating = fields.Float() # Approximate is fine for ratings
# Avoid
price = fields.Float() # Precision issues with money
Use Meaningful Defaults¶
# Good
is_active = fields.Boolean(default=True)
view_count = fields.Integer(default=0)
# Avoid
status = fields.String() # No default, must always specify
Document with AI Metadata¶
# Good
email = fields.Email(
ai_description="Primary contact email for the user"
)
# Less helpful
email = fields.Email() # What email? For what purpose?
Mark Sensitive Fields¶
# Secure
password_hash = fields.String(ai_sensitive=True, ai_agent_writable=False)
ssn = fields.String(ai_sensitive=True)
api_key = fields.String(ai_sensitive=True)
Related Documentation¶
- Models — Model definition basics
- Relations — Relationship fields in depth
- Querying — Filter and retrieve data
- Custom Fields — Create your own field types