Field types
How to choose a field type, and a reference for all 28 of them — storage, validation, and where each is configured.
#Field types
A field's type is the one decision everything else hangs off. It settles how the value is stored, what the API will accept, which validation rules apply, what the dashboard draws to edit it, and how it can be filtered and sorted.
It is also the one property you cannot change later without a migration, so it is worth a minute up front. Everything else on a field — its label, its rules, its layout, how it is displayed — can be changed whenever you like.
#Choosing a type
Start from what the value is, not from what it looks like:
| You are storing | Use |
|---|---|
| A name, a title, a paragraph | text |
| Text with formatting | markdown or richtext |
| A number you will compare or total | integer, decimal, or number |
| Yes or no | boolean |
| A moment, a day, a time of day | datetime, date, time |
| One value from a list you define | enum |
| A link to another record | relation |
| An uploaded image or document | file |
| A place on a map | One of seven geo types |
| A copy of a field on a related record | lookup |
| A count or total over related records | rollup |
| A whole document — several values as one unit | json, usually with a structure |
Three of those choices are worth a second look, because picking the wrong one is the mistake that costs a migration later.
enum or a relation? An enum's list lives in the schema, so changing it
is a schema edit. Use one when the list is short, stable, and yours —
draft / published / archived. When the options are data an editor should be
able to add to without touching the schema, make them a collection and point a
relation at it.
json or its own collection? A JSON field holds a document that belongs to
exactly one record and is read with it — a set of tags, a score card, opening
hours. Give it a structure and it is checked on write and
gets a generated form, so it is not a free-for-all. But its contents cannot be
filtered, sorted or aggregated on the way most fields can. The moment you want
to ask "which records have X", or the thing has a life of its own, it is a
collection with a relation.
text or one of its subtypes? email, url, slug, uuid, markdown,
richtext, code and json all store as TEXT. Choosing the subtype does not
change the column — it changes what the dashboard draws, what is validated, and
what your own code can assume. Choose it: there is no cost, and a bare text
field tells nobody anything.
#Where a field is configured
In the dashboard, open a collection's Settings and edit any field. There are three tabs:
| Tab | What it holds |
|---|---|
| Field | What the field is — name, type, required, unique, indexed — and how it looks: what an editor sees filling it in, and what a reader sees in tables and views. |
| Rules | What the value may contain — length, range, format, custom rules, and a JSON field's structure. |
| Raw | Every setting above as JSON, for copying a whole field to another field or another collection. |
Through the API the same settings are one object in the collection's fields
array — see Collections API. Everything the
dashboard can set, the API can set, and the sections below give both.
Name and type are locked once the field exists. They are part of the physical column, so changing either is a migration rather than a setting. Everything else can be edited at any time.
#All field types
| Type | SQLite storage | Use for | Searchable |
|---|---|---|---|
text |
TEXT |
Names, titles, short descriptions | Yes |
email |
TEXT |
Email addresses (auto-validated format) | Yes |
url |
TEXT |
Web URLs | Yes |
slug |
TEXT |
URL-safe identifiers like my-blog-post |
Yes |
uuid |
TEXT |
UUID v4 values (auto-generated if left empty) | No |
number |
REAL |
Floating-point numbers | No |
integer |
INTEGER |
Whole numbers | No |
decimal |
REAL |
Precise decimals for prices and measurements | No |
boolean |
INTEGER |
True/false values (stored as 0/1, returned as true/false) |
No |
datetime |
INTEGER |
Full timestamps (Unix seconds) | No |
date |
INTEGER |
Date-only values | No |
time |
INTEGER |
Time-only values | No |
enum |
TEXT |
Fixed choice list like draft, published, archived |
Yes |
json |
TEXT |
Any JSON document: objects, arrays, numbers, booleans | No |
code |
TEXT |
Source code with syntax highlighting support | No |
richtext |
TEXT |
Rich HTML content | Yes |
markdown |
TEXT |
Markdown content | Yes |
relation |
TEXT |
Foreign key to another collection | No |
file |
TEXT |
Reference to an R2-hosted file | No |
point |
12 columns | Lat/lng coordinate with S2 cell spatial indexing | No |
linestring |
12 columns | A geographic line through several coordinates (GeoJSON) | No |
polygon |
12 columns | Closed geographic area (GeoJSON) | No |
multipoint |
12 columns | Several scattered coordinates as one value (GeoJSON) | No |
multilinestring |
12 columns | Several disconnected lines as one value (GeoJSON) | No |
multipolygon |
12 columns | Several separate areas as one value (GeoJSON) | No |
geometry |
12 columns | Any of the six GeoJSON geometry types | No |
lookup |
mirrors source | Server-maintained copy of a field on a related record | No |
rollup |
mirrors result | Server-maintained count or total over related records | No |
lookup and rollup are server-computed: the platform fills them and
values you submit for them are ignored. They are real, indexable columns, so
unlike anything inside a json field they can be filtered and sorted on.
#System fields
Every collection includes these fields automatically. You cannot use these names for custom fields:
| Field | Type | Description |
|---|---|---|
id |
TEXT PRIMARY KEY |
Unique record identifier (UUID without hyphens) |
created_at |
INTEGER NOT NULL |
Unix timestamp when the record was created |
updated_at |
INTEGER NOT NULL |
Unix timestamp of the last update |
created_by |
TEXT |
User ID of the record creator |
updated_by |
TEXT |
User ID of the last updater |
deleted_at |
INTEGER |
Soft-delete timestamp (NULL for active records) |
#Field configuration
Every field supports these options:
{
name: 'price', // Required: lowercase a-z, 0-9, underscores. Max 50 chars.
type: 'decimal', // Required: one of the 23 types
required: true, // NOT NULL constraint
unique: false, // No two live records may share this value
indexed: true, // Create a database index (faster filtering and sorting)
label: 'Product Price', // Human-readable label for the dashboard
description: 'Price in USD',
defaultValue: 0.00, // Default value for new records
translatable: false, // Store one value per language (see Content translations)
}
#Copying a field's settings
The field editor's Raw tab shows every setting on the field as text — the same object the API stores. Copy it, open another field (in this collection or any other), paste it into that field's Raw tab and press Apply.
That is the practical way to reuse a field that took some building: a JSON field's structure, its enforcement dial, its validation rules, its display renderer and its layout all travel together.
Two things never come across:
- The name. This copies settings, not identity — records, views, filters and flows already refer to the field you are pasting into by its own name.
- The type, once the field exists. Changing the type of a saved field is a column migration rather than a setting, which is why the Field tab's type picker is disabled for it too. On a field you are still adding, the pasted type applies.
Apply also points out anything that refers to something outside the field — a relation's target collection, a lookup's source, the fields a show/hide rule watches, a form group. Those names come across verbatim, and in a different collection they may not match anything.
The box is a view, not a draft: it re-reads the field every time you open the tab, and nothing changes until you press Apply.
#Field descriptions
description is guidance for whoever fills the field in. It appears in the dashboard as an ⓘ
beside the field's label, and the text shows on hover or when the icon is focused from the
keyboard.
It is deliberately not printed under the label. A description rendered as a paragraph takes up vertical space in the form, so on a two-column layout a field with a few lines of prose pushes its own input well below the input of the field beside it, and the form ends up looking ragged for a reason that has nothing to do with the data. Behind the icon, every field occupies the same height whether it has a description or not, and long descriptions cost nothing.
Two things follow from that:
- Write as much as the field needs. There is no layout penalty for a thorough description, so explain the edge cases rather than trimming to fit.
- It is not a label. Anything a person must read before they can answer belongs in
label, because a tooltip only appears when someone goes looking for it.
The description stays available to screen readers — the input references it, so it is announced with the field rather than being hidden along with the paragraph.
The same treatment applies wherever EmuView renders a described field: record forms, Automate operation config, and Lens block and renderer settings.
#Unique fields
unique: true means no two live records may share the value. Uniqueness is enforced by a
partial index scoped to non-deleted rows, which has two consequences worth knowing:
Soft-deleting a record frees its value. Delete a member with the username
mattand a new member can take it. This matches whatlist()shows you — a tombstone is invisible to reads, so it does not hold a value hostage either. UsepurgeDeleted()if you need the row gone entirely.A collision answers
409 already_existswith the offending field named indetails, so a retry can regenerate just that value rather than guessing from the message:{ "error": "already_exists", "message": "A record with this username already exists.", "details": { "constraint": "unique", "field": "username" } }
Uniqueness can be added to a field after the collection exists. The request fails if the collection already holds duplicate live values for that field — clean them up first. Duplicates that exist only among soft-deleted rows don't block it.
Collections created before EmuView 0.2.2 carry the older column-level constraint, where a
soft-deleted row keeps reserving its value.
uniqueon those behaves as it always has until the collection is rebuilt.
Translatable fields cannot be unique — the column stores a per-locale map rather than one scalar.
#Translatable fields
Text-like fields (text, rich text, markdown, code, slug, JSON) can be marked
translatable: true to store one value per language. Enum option labels
(enumOptionLabels) and field labels (labelI18n) can also be localized while the
stored value stays stable. See Content translations
for the editor, the ?locale= API, and setup.
#Validation rules
Add validation constraints through the validationRules object:
{
name: 'sku',
type: 'text',
required: true,
validationRules: {
minLength: 4,
maxLength: 20,
regexPattern: '^[A-Z]{2}\\d{4}$', // Must match: AB1234
regexMessage: 'SKU must be 2 uppercase letters followed by 4 digits',
},
}
| Rule | Applies to | Description |
|---|---|---|
minLength / maxLength |
Text types | Character count limits |
regexPattern |
Text types | Custom regex validation |
format |
Text types | Built-in format: alphanumeric, slug, uuid, ip, lowercase, uppercase |
min / max |
Number types | Numeric range limits |
integerOnly |
number |
Reject floating-point values |
multipleOf |
Number types | Value must be divisible by this number |
allowedMimeTypes |
file |
Restrict accepted file types (e.g., ['image/jpeg', 'image/png']) |
maxSizeBytes |
file |
Maximum file size in bytes |
#Enum fields
Enum fields require an enumValues array that defines the allowed choices:
{
name: 'status',
type: 'enum',
required: true,
enumValues: ['draft', 'published', 'archived'],
}
The column is plain TEXT; the allowed list is enforced when a record is written, and a value outside it is rejected with 422 naming the permitted choices.
Editing the list later is supported. Adding a value makes it immediately writable, and existing rows are untouched. Removing a value stops new writes from using it but does not rewrite rows that already hold it — history stays readable, and the next write to that field must use a current value.
Enforcement is deliberately not a SQLite CHECK constraint. SQLite cannot alter a CHECK, so a constraint written at CREATE TABLE time would freeze the choice list for the life of the collection — before 2026-07-29 it did exactly that, and adding a value left the declared and enforced lists disagreeing. Collections created before that date have their column rebuilt the next time their enumValues change.
#Relation fields
Relation fields create foreign key links between collections. See Relations for a detailed guide.
{
name: 'category',
type: 'relation',
relationConfig: {
targetCollection: 'categories',
displayField: 'name', // Field shown in the dashboard dropdown
relationType: 'belongsTo', // or 'hasMany'
onDelete: 'set-null', // 'cascade' | 'restrict' | 'set-null'
},
}
#Geographic fields
There are seven geo types. Six of them name one GeoJSON geometry and accept only that one — a multipolygon field refuses a bare Polygon exactly as a polygon field refuses a MultiPolygon — and geometry accepts any of the six:
| Type | Accepts only |
|---|---|
point |
Point |
linestring |
LineString |
polygon |
Polygon |
multipoint |
MultiPoint |
multilinestring |
MultiLineString |
multipolygon |
MultiPolygon |
geometry |
any of the above |
GeometryCollection is not supported in any of them — it mixes geometry types inside one value, which nothing downstream can index consistently. Store one as a json field.
All seven expand into twelve physical columns for spatial queries:
| Column | Type | Description |
|---|---|---|
{name}_json |
TEXT |
Full GeoJSON representation |
{name}_lat |
REAL |
Latitude (point or centroid) |
{name}_lng |
REAL |
Longitude (point or centroid) |
{name}_rep_lat |
REAL |
Representative point latitude, always inside the geometry |
{name}_rep_lng |
REAL |
Representative point longitude |
{name}_s2_13 |
INTEGER |
S2 cell ID at level 13 (coarse, regional queries) |
{name}_s2_17 |
INTEGER |
S2 cell ID at level 17 (fine, local queries) |
{name}_bbox |
TEXT |
Bounding box [west, south, east, north] (NULL for points) |
{name}_bbox_w … _bbox_n |
REAL |
The same four edges as indexed numbers |
See Geo fields for the full storage architecture, including why a geometry stores two points and how lines and polygons are indexed by their whole extent.
#JSON fields
JSON fields store any JSON document as stringified TEXT. Accepted write values:
| You send | Result |
|---|---|
| Object or array | ✅ Stored as-is |
Number or boolean (5, true) |
✅ Stored as-is (bare scalars are valid JSON documents) |
| String | ⚠️ Treated as raw JSON text — must JSON.parse ('"hello"', '{"a":1}' ✅; 'plain text' ❌ rejected) |
The string rule is deliberate: accepting arbitrary strings would silently mask malformed-JSON typos. To store a plain string value, send its JSON-encoded form (JSON.stringify(value)) — it parses back to the string on read.
Reads always return the parsed JSON value, never the stored text — an object comes back as an object, 42 as the number 42, false as false. There is no need to JSON.parse a json field in your client:
// GET /api/v1/collections/settings/records/...
{ "key": "theme", "value": { "primary": "#6750A4" } } // value is a real object
{ "key": "retries", "value": 42 } // and scalars stay typed
#Enforcing a shape
By default a JSON field accepts any valid JSON. That is the right default for a scratchpad and the wrong one for data that always has the same shape.
Give the field a structure and three things change:
- It is checked on write, by the same rules in the dashboard and through the API. You choose how strictly — off, report, or reject.
- Editors get a form, generated from the shape: chips for choices, number boxes for numbers, add and remove buttons for lists. Not a code editor.
- Your apps can ask what the choices are. Every allowed value declared in a structure is served over the API, so a mobile app renders the same pickers without hard-coding your vocabulary.
JSON structure covers it in full, from a single tag row to a nested card, with the dashboard steps and the API payload side by side.
For large JSON payloads, enable R2 storage:
{
name: 'metadata',
type: 'json',
options: {
storeInR2: true, // Store in R2 instead of D1 for large payloads
r2Bucket: 'json-data',
r2PathPrefix: 'metadata/',
},
}
JSON fields support operator-based updates ($set, $push, $pull, $unset) for fine-grained mutations without replacing the entire object. See the records API reference for details.