SurveyLoopr
Tips & Tricks
11 min read

Unique IDs in XLSForm: uuid(), once() and concat()

Mehrab Ali
Mehrab AliPublished October 24, 2026

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.

Download example form (.xlsx)All six examples in one XLSForm, one group each. Tested with pyxform and ODK Validate; free to reuse under CC BY 4.0.Download

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:

PlatformColumn in the export
ODK CentralKEY (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
SurveyCTOKEY, 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():

typenamelabelcalculation
calculaterecord_idonce(uuid())
noteshow_idRecord ID: ${record_id}
surveychoicessettings

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:

typenamelabelcalculation
calculateshort_idonce(uuid(8))
noteshow_shortShort ID: ${short_id}
surveychoicessettings

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.

Warning:

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):

typenamelabelcalculationconstraint
select_one districtdistrictWhich district is the household in?
integervillage_codeVillage code (1 to 99). >= 1 and . <= 99
integerhh_numberHousehold number in the village listing (1 to 999). >= 1 and . <= 999
calculatevillage_padsubstr(concat('00', ${village_code}), string-length(${village_code}))
calculatehh_padsubstr(concat('000', ${hh_number}), string-length(${hh_number}))
calculatehh_idconcat(${district}, '-', ${village_pad}, '-', ${hh_pad})
noteshow_hh_idHousehold ID: ${hh_id}. Write it on the household card.
surveychoicessettings
list_namenamelabel
district10North district
district20South district
district30East district
surveychoicessettings

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:

typenamelabelcalculation
select_one districtdistrictWhich district is the household in?
integerhh_numberHousehold number in the village listing
calculatehh_id_fixedonce(if(${district} != '' and ${hh_number} != '', concat(${district}, '-', ${hh_number}), ''))
noteshow_fixedHousehold ID: ${hh_id_fixed}
surveychoicessettings

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:

typenamelabelcalculation
texthh_codeWhat is the household ID on the card?
begin_repeatmemberHousehold member
textmember_nameWhat is this member's name?
calculatemember_noposition(..)
calculatemember_idconcat(${hh_code}, '-', substr(concat('0', ${member_no}), string-length(${member_no})))
calculatemember_uuidonce(uuid())
noteshow_member${member_name} has member ID ${member_id}
end_repeatmember
surveychoicessettings

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:

typenamelabeldefaultcalculationconstraint
textenum_codeWhat is your enumerator code?${last-saved#enum_code}
integerserialInterview number on this phonecoalesce(${last-saved#serial}, 0) + 1. >= 1
calculateinterview_idconcat(${enum_code}, '-', ${serial})
noteshow_interviewInterview ID: ${interview_id}
surveychoicessettings

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.

Note:

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 wroteWrite insteadError 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 rowAdd the question, or fix the nameCould 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 rowLeave relevant emptyNo 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:

PlatformWhat to know
ODK (XLSForm)uuid(), uuid(length), once() and ${last-saved#field} all work. Built-in key: instanceID.
KoboToolboxSame XLSForm syntax. In the Formbuilder, add a Calculate question and type the expression. Built-in key: _uuid.
SurveyCTOconcat() 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.

Tip:

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.

Build my first form free

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.