JSON structure
Give a JSON field an enforceable shape, edit it as a form instead of raw JSON, and check the records you already have.
#JSON structure
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 — a set of tags, a score card, hours per travel mode. Structure is how you say what the shape is, so the platform can check it and build a form from it.
Adding a structure changes nothing about how the value is stored, and removing one leaves every stored value untouched.
#What you get
| Without a structure | With one | |
|---|---|---|
| Editing | A code editor | A form — chips, number boxes, add/remove rows |
| On save | "Is this valid JSON?" | "Is this the right shape, with allowed values?" |
| Through the API | Same | Same rules, same messages, enforced server-side |
| For your apps | Nothing to ask for | An endpoint listing every allowed value |
#Is this the right tool?
A structured JSON field and a related collection solve overlapping problems, and the wrong pick is expensive to undo. The question is not how complicated the data is — it is what you will need to ask of it.
Use a structured JSON field when the value:
- belongs to exactly one record and is always read with it,
- is edited as one thing — you set the tags, not a tag, and
- is never something you filter, sort or aggregate the whole collection by.
Reach for a collection and a relation the moment any of those stops being true. In particular:
| If you need to… | JSON field | Collection |
|---|---|---|
| Show a form built from a fixed shape | ✅ | ✅ |
| Reject values outside a list you define | ✅ | ✅ |
Ask "which records have alpine?" across the whole table |
❌ | ✅ |
| Sort or total on it | ❌ | ✅ |
| Let editors add options without a schema change | ❌ | ✅ |
| Attach permissions, history or its own workflow to the values | ❌ | ✅ |
The dividing line is querying. Everything inside a JSON field is opaque to filters and sorts, no matter how well described the structure is. That is the price of keeping it in the record, and it is a fair one for tags, score cards and opening hours — and a bad one for anything you will slice the collection by.
Between the two sits enum: one value from a fixed list, in its own column, so
it filters and sorts like any other field. If a structure is turning into a
single choice, that is what you want instead.
#The pieces
A structure is a tree of entries. Every entry has a Holds setting saying what goes in it, and that one menu is the whole vocabulary:
One value
- Text, Whole number, Number, Yes / no, Date, Date & time — an ordinary value.
- One choice — a value restricted to a list you define.
Several of the same value
- Several from a list — the tag row. Any number of values, each one from the list you define. This is a single pick, not a list you then have to configure: choose it and the allowed-values box appears on the same row.
- Several lines of text, Several numbers — the same idea without a fixed vocabulary.
Containers
- Group of entries — a fixed set of named entries, like
biomeandenvironment, or the six components of a grade. You name each one, and each can hold something different. - Repeating group — any number of entries that are all the same group, like a list of sections each with a title and a body.
- Named entries (any name) — entries whose names come from your data rather than from the structure, all holding the same kind of thing.
Only the last three add a level to the tree. Several from a list is one idea — "the biomes" — so it is one row in the builder and one level in the structure, even though the stored JSON is an array.
A structure authored through the API can hold shapes the menu does not name — a list of lists, for instance. The builder keeps them, shows the nested rows, and marks the entry Advanced — nested by hand rather than flattening something it cannot express.
#Duplicate
Three tag groups differ only in their name and their values. Build the first,
then use the duplicate button on its row: the copy lands directly beneath
with _copy on its name, values and all. Rename it, edit the list, done.
#Group of entries, or named entries?
They look the same in a record and are very different to author. The question is whether you know the names when you design the field:
| Group of entries | Named entries | |
|---|---|---|
| The names | You list them | Whoever writes the record chooses them |
| Each entry | Can be a different shape | All the same shape |
| A misspelled name | Reported as unexpected | Accepted — it is just another name |
| Good for | biome, environment, a grade card |
Per-user tallies, per-site counts, IDs you do not control |
Reach for group of entries first. If you find yourself unable to finish the list of names, that is the signal for named entries — but you give up the protection against typos in them, because there is nothing to compare against.
You can get some of it back: a named-entries container has an Allowed names box. Leave it blank for anything, or list the names you will accept and a record using any other name is reported like any other mismatch.
#Getting started
Open a JSON field in the schema editor and go to Rules. A JSON field's shape is its validation, so that is where the builder lives. You will not be dropped into an empty builder — pick whichever start is closer:
A preset. Tag groups, Score card, Named lists of numbers, or a blank single entry. Rename things and go.
An example value. Paste a real value from your data and the structure is proposed for you. A list of short repeated strings becomes a choice with those values already filled in, whole numbers become whole numbers, and nested groups come through nested. It lands close enough that you are editing rather than authoring.
Either way you land on Warn, and unexpected entries are left alone — a shape proposed from one record has not seen the rest of your data yet.
#Reusing a structure
A structure you have built once can be copied whole: the field editor's Raw tab shows every setting on the field as text, including the structure, the enforcement dial and the unexpected-entries setting. Copy it, open the field you want configured the same way — in this collection or another — paste it into that field's Raw tab and press Apply. The field keeps its own name.
See Copying a field's settings.
#Writing a structure through the API
Everything the builder produces is a plain object on the field, so a structure can be written directly — in a migration, a seed script, or by an assistant authoring a schema for you.
{
"name": "tag",
"type": "json",
"label": "Tags",
"structure": {
"enforce": "warn",
"unknownKeys": "reject",
"root": {
"name": "root",
"type": "object",
"fields": [
{
"name": "biome",
"type": "list",
"label": "Biome",
"uniqueItems": true,
"items": { "name": "value", "type": "enum", "enumValues": ["coastal", "karst"] }
}
]
}
}
}
structure has exactly three keys:
| Key | Values | Meaning |
|---|---|---|
enforce |
off | warn | strict |
Whether mismatches are ignored, reported, or rejected on write. Start at warn. |
unknownKeys |
allow | reject |
What happens to keys an object node does not declare. |
root |
one node | The shape itself. Almost always an object. |
#The node grammar
Every node in the tree is the same object. name and type are required;
everything else is optional and most of it is shared with an ordinary field,
meaning the same thing it means there.
{
name: string, // the JSON key. Ignored for a list's items and a map's values.
type: 'text' | 'number' | 'integer' | 'decimal' | 'boolean'
| 'datetime' | 'date' | 'time' | 'enum'
| 'object' | 'list' | 'map',
label?: string, // shown above the input; falls back to the humanised name
labelI18n?: { [locale: string]: string },
description?: string,
required?: boolean, // a missing key AND an explicit null both fail
defaultValue?: string | number | boolean | null,
enumValues?: string[], // enum only — the allowed values
enumOptionLabels?: { [value: string]: { label?: string } },
validationRules?: { // same names and meanings as a field's
minLength?, maxLength?, regexPattern?, regexMessage?, format?,
min?, max?, integerOnly?, multipleOf?,
minDate?, maxDate?, futureOnly?, pastOnly?
},
interface?: string, // presenter hint, e.g. 'enum-select'
interfaceOptions?: { ... }, // e.g. { enumLayout: 'chips-select' }
// containers — one applies, depending on `type`
fields?: Node[], // object: the declared properties, in display order
items?: Node, // list: the shape of every element
minItems?: number, // list
maxItems?: number, // list
uniqueItems?: boolean, // list: reject duplicates
keys?: { enumValues?: string[], pattern?: string, label?: string }, // map
values?: Node // map: the shape of every value
}
Four rules cover almost every mistake:
- An
objectneedsfields; alistneedsitems; amapneedsvalues. A container with nothing inside it is refused when you save the structure, not later. - An
enumneeds a non-emptyenumValues. An enum with no values can never be satisfied, so it is refused too. - A list's
items.nameis ignored. The validator buildsbiome[0], neverbiome.value."name": "value"is a convention, nothing more. - Several of one plain value is a
listwhoseitemsis a leaf — that is the tag row, and it costs no depth level. Alistwhoseitemsis anobjectis a repeating group, and that one does.
Save it like any other field change:
PATCH /api/v1/collections/trails
{ "fields": [ … the full field list, with `structure` on the json field … ] }
The structure is validated when it is saved. A container with nothing in it, a duplicate key, an enum with no values, or a tree past the limits is rejected there with a message naming the offending path — before any record is written against it.
#Worked examples
Four shapes, simplest first. Each gives the value being stored, how to build it
in the dashboard, and the structure that produces it — the two are the same
thing seen from either side.
#One row of tags
The simplest useful structure. One entry, several values, all from a list you define.
{ "biome": ["grassland", "alpine"] }
In the dashboard. Add an entry, name it biome, set Holds to Several
from a list, and type the allowed values. One row.
As a structure.
{
"enforce": "warn",
"unknownKeys": "reject",
"root": {
"name": "root",
"type": "object",
"fields": [
{
"name": "biome",
"type": "list",
"label": "Biome",
"uniqueItems": true,
"items": {
"name": "value",
"type": "enum",
"enumValues": ["grassland", "alpine", "rainforest"]
}
}
]
}
}
uniqueItems is what stops the same tag being picked twice. Editors see a row
of chips.
#Tags, with a different list per group
{ "biome": ["grassland", "alpine"], "environment": ["natural", "rural"] }
A group of entries with one entry per tag group, each holding several
from a list. Each carries its own allowed values — biome and environment
never share a list. Editors see a row of chips per group.
That is two rows per group in the builder and two levels in the structure, so you have room to put the whole thing inside something else if you need to.
In the dashboard. Build the first group as above, then use the duplicate
button on its row: the copy lands beneath it with _copy on its name. Rename
it, change its values, repeat.
As a structure — the same node again, with different values:
{
"enforce": "warn",
"unknownKeys": "reject",
"root": {
"name": "root",
"type": "object",
"fields": [
{
"name": "biome",
"type": "list",
"label": "Biome",
"uniqueItems": true,
"items": { "name": "value", "type": "enum", "enumValues": ["grassland", "alpine"] }
},
{
"name": "environment",
"type": "list",
"label": "Environment",
"uniqueItems": true,
"items": { "name": "value", "type": "enum", "enumValues": ["natural", "rural", "urban"] }
}
]
}
}
#Hours per travel mode
{ "walk": [1, 2.5, 4], "run": [0.5] }
A group of entries, one per mode, each holding several numbers. Set a
minimum and maximum — they read Minimum (each) and Maximum (each),
because they apply to every number in the list — and a typo like 250 is
caught on the way in. Add a suffix of h and the form shows the unit.
{
"name": "walk",
"type": "list",
"label": "Walk",
"items": {
"name": "hours",
"type": "decimal",
"validationRules": { "min": 0, "max": 24 }
}
}
The rules sit on items, not on the list — they describe each number, which is
why the builder labels them (each).
Ordering the hours is a job for a flow, not for the structure — structure describes what is allowed, it never rewrites what you sent.
#A grading card
{ "as21561": { "t": 3, "g": 2, "s": 4, "i": 3, "e": 6, "w": 4 } }
A group of entries containing another group, whose six entries are whole
numbers with a minimum of 1 and a maximum of 6. Leave them optional and a blank
box means "not assessed" — which is different from zero, and is stored as
null.
This is the deepest of the four: root → as21561 → t, three levels of the
four allowed.
{
"enforce": "warn",
"unknownKeys": "reject",
"root": {
"name": "root",
"type": "object",
"fields": [
{
"name": "as21561",
"type": "object",
"label": "AS 2156.1",
"fields": [
{
"name": "t",
"type": "integer",
"label": "Track",
"validationRules": { "min": 1, "max": 6, "integerOnly": true }
}
]
}
]
}
}
#A tally whose names you do not control
{ "user_8fa21": 4, "user_1c093": 11 }
Named entries holding a whole number. Nobody can list those names in advance, so a group of entries is the wrong tool — you would be editing the schema every time someone new appeared.
The shape is still checked: every value must be a whole number, and a negative
one is still rejected if you set a minimum. Only the names are open. If they
do follow a rule, put it in Allowed names — or, through the API, a pattern
on keys, which the builder preserves even though it does not offer a box for
it yet.
{
"name": "root",
"type": "map",
"keys": { "pattern": "^user_[a-z0-9]+$", "label": "User" },
"values": { "name": "count", "type": "integer", "validationRules": { "min": 0 } }
}
Note what changed: fields became values, because every entry has the same
shape and only the names differ.
#Enforcement, and the records you already have
This is the part that matters if the field already holds data.
| Mode | New saves | Records you already have |
|---|---|---|
| Off | Never rejected | Untouched |
| Warn | Never rejected, mismatches reported | Reported by the check below |
| Strict | Rejected when they do not match | Readable; must be fixed to save again |
A new structure always saves as Warn, and Strict cannot be selected until you have run Check existing records. Turning on a rule you have not checked is how a schema edit becomes an outage.
Unexpected entries is the second dial, right below the first, and it decides what happens to keys your structure does not list:
- Report anything not listed — a stray
enviromentis a mismatch, so the check finds it and Strict rejects it. Choose this once you are confident the structure is complete; it is what catches typos in entry names. - Leave anything not listed alone — extra keys pass silently. Choose this while a structure is new, or when records legitimately carry more than the structure describes.
Whichever you pick, an unexpected entry is never deleted. Removing a value to make a record valid would lose data silently, so the only outcomes are "reported" and "ignored".
This dial is about groups of entries, which are the only containers with a list of expected names. A named entries container accepts any name by design; constrain it with its own Allowed names box instead.
Check existing records reads every record and groups what it finds by cause:
118 of 3,412 records need attention.
84× biome[] "grasslands" is not an allowed value. Did you mean "grassland"?
31× $ "enviroment" is not a recognised entry. Did you mean "environment"?
3× walk[] Hours must be a number (got the text "2.5h" — remove the quotes).
Grouping is the point. 118 records are invalid is unactionable; 84 records have a plural typo is one substitution.
And you can apply that substitution. A group with exactly one right answer —
a value that should be a different allowed value, a key that should be a
different name — gets a checkbox. Tick the ones you want and press Fix, and
every affected record is corrected for you. Groups with more than one sensible
answer ("2.5h" where a number belongs) have no checkbox on purpose: a guess
applied to twelve thousand records is not a fix.
Three things are worth knowing before you press it:
- Each repair is an ordinary edit. Fixing 12,500 records fires 12,500
record.updatewebhooks and Automate triggers. That is correct — they really are edits — but it is worth checking what those flows do first. - Records you cannot edit are not edited. The repair runs as you, so any access rules that scope you to your own records still apply. What it could not save is reported.
- Some records will need more than one pass. A record with a second problem nobody chose to fix is still rejected while the rule is Strict. The result says so — "9,900 fixed, 2,600 still have other problems" — rather than claiming success.
Nothing is ever deleted to make a record valid. A fix rewrites a value or renames a key, and that is all it can do.
While the check runs you will see "up to N may need attention". That is a ceiling, not a count — the database can find rows that might be wrong far faster than it can confirm which ones actually are, so the number you see first is deliberately generous and can only come down. The exact figure appears when the check finishes. On a collection with nothing wrong, the check reads no records at all.
The check runs on the server, so you can close the tab and come back to it. Stop ends it immediately and keeps whatever it found — a partial report is still real. Because it is partial it will not unlock Strict; only a check that ran to the end with nothing wrong does that.
You can keep editing the structure while a check runs. If you do, the check stops itself and says its results are out of date, rather than answering a question you have already changed your mind about.
In Strict, a record holding a mismatched value cannot be saved even if you were editing something else on it. That is deliberate — the alternative is that "strict" does not mean strict and the data never converges — and it is why the check exists and gates the switch.
#Editing a record
The form is built from the structure: chips for choices, number boxes for numbers, add and remove buttons for lists. Anything that does not match is flagged on the entry that caused it, not as a sentence about the whole field.
A field that matches says nothing. Silence is the success state — a badge confirming every well-formed value on every record is a line to read past, and it competes with the messages that do need reading.
To edit the value as text, use the record editor's Raw tab, which shows the whole record as JSON.
#In a table
A structured field reads as its own labels wherever a row is one line tall — the collection table, and a Lens table block:
Biome: Grassland, Kast · Environment: Urban
Entries appear in the order the structure declares them, so two records read the same way down the column, and the display labels you set for choices are the ones shown. A key a record carries that the structure does not declare is still shown, at the end — a display that hid it would report the record as something it is not.
A JSON field with no structure has no shape to read, so it stays as JSON — compact, on one line, with the keys and punctuation dimmed so the values stand out.
Either way the cell is one line and ellipses what does not fit. To see the whole document, open the record.
The pretty-printed block is still available: choose JSON as the field's display renderer under Field → Appearance. It is only the default that differs between a table cell and everywhere else.
#Getting the allowed values into your app
Anything you list as a choice is available to API clients, so a mobile app or a front end can render the same pickers without hard-coding your vocabulary:
GET /api/v1/collections/trails/fields/tag/options
{
"field": "tag",
"type": "json",
"optionSets": [
{
"path": "$.biome[*]",
"label": "Biome",
"multiple": true,
"values": [
{ "value": "grassland", "label": "Grassland" },
{ "value": "alpine", "label": "Alpine" }
]
}
]
}
With the SDK:
const { optionSets } = await sdk.collection('trails').fieldOptions('tag');
const all = await sdk.collection('trails').options(); // every constrained field
The same endpoint answers for ordinary choice fields too — one set at path $.
It returns values and labels only: never your rules, never the enforcement
setting. Pass ?locale= for translated labels.
#Checking existing records from the API
The check is a job. You start it, and poll it — you never drive the loop:
POST /api/v1/collections/trails/fields/tag/scan-jobs → 202 { "jobId": … }
GET /api/v1/collections/trails/scan-jobs/{jobId}
DELETE /api/v1/collections/trails/scan-jobs/{jobId}
const job = await sdk.collection('trails').startStructureScan('tag');
const done = await sdk.collection('trails').getStructureScan(job.jobId);
await sdk.collection('trails').cancelStructureScan(job.jobId);
status is running, complete, cancelled, superseded (the structure
changed while it ran) or failed. estimate.upperBound is the ceiling
described above — narrowed: false means no ceiling could be worked out and it
is simply the record count. groups is the grouped report.
Starting or reading a check needs permission to manage the schema, the same as editing the field.
Repairs work the same way, and take the fixes you want applied:
POST /api/v1/collections/trails/fields/tag/repair-jobs
GET /api/v1/collections/trails/repair-jobs/{jobId}
DELETE /api/v1/collections/trails/repair-jobs/{jobId}
const fixes = done.groups
.filter((g) => g.from && g.suggestion)
.map((g) => ({ kind: g.code, path: g.path, from: g.from, to: g.suggestion }));
const repair = await sdk.collection('trails').startStructureRepair('tag', fixes);
Read repair.repaired, repair.skipped and repair.failed rather than the
status alone — a repair finishing is not the same as every record being fixed.
#Limits
| Limit | Value | Why |
|---|---|---|
| Depth | 4 levels | Deeper than this is a related collection, not a field |
| Entries | 100 | Something that large is a collection |
| Nested lists | 2 | A list of lists of lists has no sensible form |
| Allowed values | 500 per choice | Keeps the options response small |
Several from a list, several lines and several numbers do not cost a level — an entry holding several plain values is one idea, not a step down the tree. Only a group, a repeating group or named entries take you a level deeper.
These are checked when you save the structure, not on every write, so you find out while you are looking at the thing you just built.
#What structure does not do
- It does not change or reorder your data. Sorting, lower-casing and tidying are jobs for a flow. Structure only ever accepts or rejects.
- It does not delete unexpected entries. They are reported or ignored, never removed.
- It does not make the field filterable. Querying inside a JSON value is a separate capability. When you need to filter or aggregate on something, a relation or its own collection is still the right home.
- It cannot check against other records. "Every tag must exist in the tags
collection" needs a flow — an
event_filterflow can read other collections and block a write. Structure is evaluated on its own.
#Rules that span several entries
A rule that has to look at more than one entry at a time — total hours must not exceed 24 — belongs on the field rather than inside the structure. Set it under Rules → Custom rule, written as a JSONata expression:
$sum($append($value.walk, $value.run)) <= 24
$value is the whole field value, so the rule can reach any part of it. Tick
Enforce via API to have it applied to every write, not just dashboard edits.