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 matt and a new member can take it. This matches what list() shows you — a tombstone is invisible to reads, so it does not hold a value hostage either. Use purgeDeleted() if you need the row gone entirely.

  • A collision answers 409 already_exists with the offending field named in details, 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.

Note

Collections created before EmuView 0.2.2 carry the older column-level constraint, where a

soft-deleted row keeps reserving its value. unique on 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.

Note

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.