Unique IDs in XLSForm: uuid(), once() and concat()
A unique ID in XLSForm is a calculate row: once(uuid()) gives every record a random ID that never changes, and concat() builds a readable ID such as 10-07-042 from earlier answers. ODK, KoboToolbox and SurveyCTO already give each submission its own ID, so you only need one of these when you want an ID of your own. Each example below compiles with pyxform and ODK Validate, the same check ODK Central runs when you upload a form.
Does every submission already have a unique ID?
Yes. pyxform adds a hidden instanceID to every form, and the app fills it with a value like uuid:3f2c9a10-... when the form is started. You do not need to add a row for it. It appears in your exports as:
| Platform | Column in the export |
|---|---|
| ODK Central | KEY (the same value as meta-instanceID) in the main table, PARENT_KEY in repeat tables |
| KoboToolbox | _uuid (the same value without the uuid: prefix), plus a numeric _id |
| SurveyCTO | KEY, with PARENT_KEY in repeat group files |
So if the only goal is to tell submissions apart or join a repeat table back to its household, use these columns and stop here. Build your own ID when people need to read it, write it on a household card, or type it into a follow-up form.
How do I generate a random unique ID?
Use uuid() in a calculate row and wrap it in once():
| type | name | label | calculation |
|---|---|---|---|
| calculate | record_id | once(uuid()) | |
| note | show_id | Record ID: ${record_id} |
uuid() returns a random version 4 UUID, 36 characters long. Without once(), the calculation runs again whenever the record is opened, so an edited submission comes back with a new ID and no longer matches anything you linked to it. once() returns the new value only while the field is still empty, then keeps the first one.
Do not put false() in the relevant column to hide the row. A calculate row is already hidden, and a non-relevant field is dropped from the submission, so the ID arrives empty. This is a common cause of "my uuid column is blank" on the ODK forum.
How do I make a shorter ID?
Give uuid() a length. uuid(8) returns a random string of 8 characters:
| type | name | label | calculation |
|---|---|---|---|
| calculate | short_id | once(uuid(8)) | |
| note | show_short | Short ID: ${short_id} |
A short ID is easier to read out over the phone, but the shorter it is, the more likely two records share one by chance. That risk stays small for a few thousand records at 8 characters, not zero. Check for duplicates in your data before you rely on it, or keep the full uuid() as the real key and use the short one only for display.
Avoid digits-only IDs cut from uuid() with translate() and substr(). A UUID can have long runs of letters, so after the letters are removed some IDs come out with 4 digits and others with 10. An ODK forum thread from a KoboToolbox user with exactly this problem has over 2,000 views.
How do I build a readable household ID with concat()?
Join the location codes and the household's number from the listing. Pad each number with substr() so every ID has the same length and sorts correctly (10-07-042, not 10-7-42):
| type | name | label | calculation | constraint |
|---|---|---|---|---|
| select_one district | district | Which district is the household in? | ||
| integer | village_code | Village code (1 to 99) | . >= 1 and . <= 99 | |
| integer | hh_number | Household number in the village listing (1 to 999) | . >= 1 and . <= 999 | |
| calculate | village_pad | substr(concat('00', ${village_code}), string-length(${village_code})) | ||
| calculate | hh_pad | substr(concat('000', ${hh_number}), string-length(${hh_number})) | ||
| calculate | hh_id | concat(${district}, '-', ${village_pad}, '-', ${hh_pad}) | ||
| note | show_hh_id | Household ID: ${hh_id}. Write it on the household card. |
| list_name | name | label |
|---|---|---|
| district | 10 | North district |
| district | 20 | South district |
| district | 30 | East district |
The padding works because substr() counts from 0. For household 7, concat('000', 7) is 0007, and starting at position 1 (the length of 7) leaves 007. For household 42 it leaves 042. The constraint keeps the number to 3 digits, so the pad never needs more zeros.
This ID is only as unique as the listing behind it. Two enumerators who give the same household number in the same village make the same ID, so check for duplicate hh_id values when data arrives.
Should I wrap a concat() ID in once()?
Usually not. A concat() ID is rebuilt from the answers, so if a supervisor corrects a wrong village code, the ID follows the correction. That is normally what you want.
If the ID must never change once it has been made, be careful: the calculation first runs when the form opens, while every answer is still empty, and once(concat(...)) can then lock in a value like --. Return an empty string until every part is answered, so once() waits:
| type | name | label | calculation |
|---|---|---|---|
| select_one district | district | Which district is the household in? | |
| integer | hh_number | Household number in the village listing | |
| calculate | hh_id_fixed | once(if(${district} != '' and ${hh_number} != '', concat(${district}, '-', ${hh_number}), '')) | |
| note | show_fixed | Household ID: ${hh_id_fixed} |
The if() statement guide explains the if(condition, value_if_true, value_if_false) pattern used here.
How do I give each household member an ID inside a repeat?
Inside a repeat group, position(..) returns the member's line number (1, 2, 3...). Join it to the household ID:
| type | name | label | calculation |
|---|---|---|---|
| text | hh_code | What is the household ID on the card? | |
| begin_repeat | member | Household member | |
| text | member_name | What is this member's name? | |
| calculate | member_no | position(..) | |
| calculate | member_id | concat(${hh_code}, '-', substr(concat('0', ${member_no}), string-length(${member_no}))) | |
| calculate | member_uuid | once(uuid()) | |
| note | show_member | ${member_name} has member ID ${member_id} | |
| end_repeat | member |
member_id gives readable IDs such as 10-07-042-01. Line numbers shift if an enumerator deletes a member in the middle of the roster, so member_uuid is there as the key that never moves: use it to join member data across files and follow-up rounds. The repeat groups guide covers rosters in more detail.
Can I make sequential IDs like R1, R2, R3?
Not across a team. Phones work offline and never see each other's records, so there is no shared counter that hands out the next number. Within one phone, ODK Collect, ODK Web Forms and KoboToolbox's Enketo can read the last saved record with ${last-saved#field}, which gives a per-phone count. Join it to the enumerator's code so two phones never collide:
| type | name | label | default | calculation | constraint |
|---|---|---|---|---|---|
| text | enum_code | What is your enumerator code? | ${last-saved#enum_code} | ||
| integer | serial | Interview number on this phone | coalesce(${last-saved#serial}, 0) + 1 | . >= 1 | |
| calculate | interview_id | concat(${enum_code}, '-', ${serial}) | |||
| note | show_interview | Interview ID: ${interview_id} |
The default column fills the next number when a new form starts, and the enumerator can still correct it. Treat it as a convenience, not a guarantee: the count starts again on a new phone or after a reinstall, the "last saved" record can be an older one that was just edited, and ODK stores the last saved record on the phone unencrypted. Keep the automatic instanceID as the real key.
instance_name on the settings sheet names each filled form in Collect's lists and on Central's submission page, for example concat(${hh_id}, ' ', ${head_name}). It is a label, not a key: nothing stops two submissions from having the same instance_name.
Common unique ID mistakes and how to fix them
| You wrote | Write instead | Error you will see |
|---|---|---|
UUID() | uuid() | cannot handle function 'UUID' |
${a} & '-' & ${b} | concat(${a}, '-', ${b}) | Couldn't understand the expression starting at this point |
concat(${a}, '-' | concat(${a}, '-') | Mismatched brackets or parentheses in expression |
concat(${a}, '-', ${b}) with no b row | Add the question, or fix the name | Could not find the name 'b' |
${a} + '-' + ${b} | concat(${a}, '-', ${b}) | No error, but + adds numbers, so the ID comes out as NaN |
uuid() without once() | once(uuid()) | No error, but the ID changes when the record is edited |
false() in relevant on the ID row | Leave relevant empty | No error, but the ID is blank in the data |
The last three rows are the dangerous ones: the form uploads and the IDs break quietly. For other messages, see common XLSForm errors, or paste your form into the free XLSForm validator.
Does this work the same in ODK, KoboToolbox and SurveyCTO?
The functions are the ODK XPath functions, so concat(), substr(), position(..) and once() work the same on all three. What differs:
| Platform | What to know |
|---|---|
| ODK (XLSForm) | uuid(), uuid(length), once() and ${last-saved#field} all work. Built-in key: instanceID. |
| KoboToolbox | Same XLSForm syntax. In the Formbuilder, add a Calculate question and type the expression. Built-in key: _uuid. |
| SurveyCTO | concat() and once() IDs work in a calculate field. Built-in key: KEY. Skip logic goes in a column named relevance. |
If a form will move between platforms, a concat() ID built from answers is the most portable choice.
Why the ID belongs in the form
An ID made during the interview links everything that happens later: the follow-up visit, the lab sample, the household's card on the wall. When the ID is built in Stata after fieldwork, there is nothing to write on the card, and a household that moved between rounds is hard to match. The note showing the ID on screen also lets the enumerator spot a wrong village code while still in the village.
You can describe the ID in plain language instead: "make a household ID from the district code, the village code padded to 2 digits and the household number padded to 3". LooprAI writes the calculate rows, then compiles the form with pyxform before anything is saved, so a missing bracket or a capital UUID never reaches your form.
Next: XLSForm calculation examples →
Frequently Asked Questions
How do I create a unique ID in XLSForm?
Add a calculate row and write once(uuid()) in its calculation column. uuid() makes a random ID and once() keeps it when the record is edited. For a readable ID, join earlier answers with concat(), such as concat(${district}, '-', ${hh_number}).
Why does my uuid() change when I edit a submission?
Calculations run again when a record is opened for editing, so uuid() returns a new value each time. Wrap it in once(), as once(uuid()). once() only fills the field while it is empty and keeps the first value after that.
How do I make a unique ID with a fixed number of digits?
Pad each number with substr(), for example substr(concat('000', ${hh_number}), string-length(${hh_number})) turns 7 into 007. Limit the number with a constraint such as . <= 999 so it never needs more zeros than you added.
Can XLSForm generate sequential IDs like 1, 2, 3?
Not across a team, because phones work offline and cannot share a counter. On one phone, a default of coalesce(${last-saved#serial}, 0) + 1 counts up. Join it to the enumerator code so different phones never produce the same ID.
Is instance_name a unique ID?
No. instance_name on the settings sheet only names each filled form in Collect's lists and on Central's submission page. Nothing checks it for duplicates, so two submissions can share one. Use instanceID, or your own uuid() ID, as the key.
How do I give each household member a unique ID?
Inside the repeat, add a calculate with position(..) for the line number and join it to the household ID with concat(). Add a second calculate with once(uuid()) as a key that does not shift when a member is deleted.
No Credit Card Required
Build the form first. Add hosting when you need it.
Start free with LooprAI, then deploy a managed ODK Central server or run DataSnap checks when your project is ready.
Automation handles the mechanical work.
Researchers decide what the evidence means.