Prevent Duplicate Answers in an XLSForm Repeat
To stop the same answer appearing twice in an XLSForm repeat, put count(../../member[member_id = current()]) = 1 in the constraint column of the question inside the repeat. It counts the repeat instances whose member_id equals this answer, the current instance included, so any value above 1 is a duplicate. The same constraint works for text, integer and barcode questions in ODK, KoboToolbox and SurveyCTO.
What is the constraint that blocks duplicates in a repeat?
A household roster records an ID card number for every member. Two members cannot share a number, so the ID question rejects any number already entered for another member:
| type | name | label | constraint | constraint_message |
|---|---|---|---|---|
| begin repeat | member | Household member | ||
| text | member_name | Name of household member | ||
| text | member_id | National ID number of ${member_name} | count(../../member[member_id = current()]) = 1 | This ID is already recorded for another member. |
| end repeat | member |
Read the expression from the inside out:
current()is the answer being checked, here this member's ID.../../memberwalks up from the question:..is this repeat instance,../..is the level that holds the repeat, and/memberselects every instance of the repeat.[member_id = current()]keeps the instances whose ID equals this answer.count(...) = 1passes when only one instance has it, which is the current one.
Use the repeat's name from the begin repeat row, not its label. Constraints do not run on blank answers, so two members with no ID yet are not flagged; make the question required if it must always be filled.
Does the same check work for barcodes and numbers?
Yes. The constraint does not care how the answer was entered. A lab sample log scans one tube per child, and a tube scanned twice means another tube was skipped:
| type | name | label | constraint | constraint_message |
|---|---|---|---|---|
| begin repeat | sample | Blood sample | ||
| integer | child_line | Line number of the child on the roster | ||
| barcode | tube_code | Scan the barcode on the sample tube | count(../../sample[tube_code = current()]) = 1 | This tube was already scanned in this form. Scan a new tube. |
| end repeat | sample |
For an integer question the comparison is numeric, so 7 and 07 count as the same answer. For text and barcode it is an exact text match: A12 and a12 are different answers. When enumerators type IDs by hand, prefer numeric IDs or a scan over free text names.
The constraint stops the duplicate on the phone, during the interview. It does not compare with other submissions or with last week's data. For IDs that must be unique across the whole project, see unique IDs in XLSForm.
Why does my join() and selected() check fail when I finalize?
The most shared forum answer for this task builds a list of every ID outside the repeat and checks the new one against it:
type,name,label,calculation,constraint
calculate,all_ids,,join(' ', ${member_id}),
begin repeat,member,Household member,,
text,member_id,Member ID,,not(selected(${all_ids}, .))
end repeat,member,,,
It compiles, and it often seems to work while you type. Then Collect refuses to finalize and sends you back to the first member with the constraint message. The reason: all_ids includes the current member's own ID. While you type, the constraint can run before all_ids updates. When you finalize, every constraint runs again against the full list, every ID finds itself in it, and every member fails.
The count(...) = 1 constraint has no such problem, because it expects to find the current answer once. If you have a form built on join(), replace the constraint and delete the all_ids calculate.
How do I hide options already picked in an earlier instance?
For a select_one inside a repeat, it is kinder to remove the used options than to reject them. A crop module asks about each crop the household grew, and each crop should come up once:
| type | name | label | calculation | choice_filter |
|---|---|---|---|---|
| begin repeat | crop_rep | Crop grown this season | ||
| calculate | crop_pos | position(..) | ||
| calculate | crops_other | join(' ', ../../crop_rep[crop_pos != current()/../crop_pos]/crop) | ||
| select_one crop | crop | Which crop is this? | not(selected(${crops_other}, name)) | |
| integer | crop_area | Area planted with this crop (decimals) | ||
| end repeat | crop_rep |
| list_name | name | label |
|---|---|---|
| crop | 1 | Rice |
| crop | 2 | Maize |
| crop | 3 | Wheat |
| crop | 4 | Jute |
| crop | 5 | Potato |
| crop | -96 | Other |
How it works:
crop_posstores the number of the current instance withposition(..).crops_otherjoins the crops of every other instance into a space-separated list, such as1 4. Excluding the current instance matters: if it included its own answer, the chosen crop would vanish from its own list when you go back to it.- The
choice_filterkeeps only the choices whosenameis not in that list.
The same filter works with a select_multiple in the repeat, since crops_other then holds every ticked name. To filter choices by a select_multiple outside a repeat, see choice filters with select_multiple.
How do I block a duplicate combination of two answers?
Names repeat in many households, so a roster that checks the name alone rejects real members. Check the name and the age together instead, on the second of the two questions:
| type | name | label | constraint | constraint_message |
|---|---|---|---|---|
| begin repeat | person | Household member | ||
| text | person_name | Name of household member | ||
| integer | person_age | Age of ${person_name} in completed years | count(../../person[person_name = current()/../person_name and person_age = current()]) = 1 | A member with this name and age is already on the roster. Check for a double entry. |
| end repeat | person |
Inside the brackets, a bare name like person_name means the field of each instance being counted. current()/../person_name means the name in the instance being checked: current() is the age, .. is its instance, and /person_name is the name next to it.
How do I check duplicates in nested repeats?
The number of ../ steps decides how far the check reaches. A farm survey lists farmers, and each farmer's plots inside a nested repeat. Plot codes restart for every farmer, so the plot code only has to be unique within one farmer:
| type | name | label | constraint | constraint_message |
|---|---|---|---|---|
| begin repeat | farmer | Farmer | ||
| text | farmer_name | Name of the farmer | ||
| begin repeat | plot | Plot | ||
| integer | plot_no | Plot number for ${farmer_name} | count(../../plot[plot_no = current()]) = 1 | This farmer already has a plot with this number. |
| text | gps_tag | Survey tag on the plot boundary marker | count(../../../farmer/plot[gps_tag = current()]) = 1 | This marker tag is already recorded for another plot in this form. |
| end repeat | plot | |||
| end repeat | farmer |
../../plotstays inside the current farmer:..is this plot,../..is this farmer.../../../farmer/plotgoes up one more level to the form and back down through every farmer, so the boundary marker tag must be unique across the whole form.
To read single answers from other instances rather than count them, use indexed-repeat().
Common duplicate-check mistakes and how to fix them
The error text below is what ODK Validate returned for each broken form. Several mistakes compile without any error and only show up on the phone:
| You wrote | Write instead | What you will see |
|---|---|---|
not(selected(${all_ids}, .)) with all_ids outside the repeat | count(../../member[member_id = current()]) = 1 | No error at upload; the form will not finalize and flags every member |
count(../../member[member_id = current()]) = 0 | ... = 1 | No error at upload; every answer is rejected, because the count includes the current instance |
count(../../member[${member_id} = current()]) = 1 | count(../../member[member_id = current()]) = 1 | No error at upload; every answer is rejected, because ${member_id} becomes ../member_id inside the brackets and matches nothing |
count(../member[member_id = current()]) = 1 | count(../../member[member_id = current()]) = 1 | No error at upload; every answer is rejected, because one ../ stays inside the current instance |
join(' ', ${member_id}) in a calculate inside the repeat | Put the calculate outside the repeat, or use count() | No error at upload; the list holds only the current answer |
count(...) == 1 | count(...) = 1 | contains invalid constraint expression ... Bad node |
When a check rejects answers it should accept, add a temporary calculate inside the repeat with count(../../member[member_id = current()/../member_id]) and show it in a note, to see what the form is counting. More messages are explained in common XLSForm errors.
Does this work the same in ODK, KoboToolbox and SurveyCTO?
Yes. count(), current(), position(..), join() and selected() are part of the ODK XPath used by all three, and relative paths like ../../member read the same way. What differs is where you type the rule:
| Platform | Where the duplicate check goes |
|---|---|
| ODK (XLSForm) | constraint column of the question inside the repeat; filters in choice_filter |
| KoboToolbox Formbuilder | Question settings, Validation Criteria, manually entered expression; use XLSForm upload for the choice filter |
| KoboToolbox (XLSForm upload) | Same as ODK |
| SurveyCTO | constraint column of the field inside the repeat group (skip logic goes in a column named relevance) |
Test the finished form in both SurveyLoopr Collect and the web form before fieldwork. Fill two instances with the same answer, check that the second is refused, then correct it and finalize, which is the step the join() method fails.
Why duplicate checks belong in the form
A duplicated household member or a tube scanned twice is cheap to fix while the enumerator is still in the house and expensive to fix in the office. After upload, someone has to decide which of two identical rows is real, and the missing tube may never be found. A constraint costs one cell, and it catches the error at the moment it happens.
You can describe the rule instead of writing it: "the member ID must be unique within the household roster, and don't show crops already picked in an earlier instance". LooprAI writes the constraint and the choice filter, then compiles the form with pyxform before it saves anything.
Next: XLSForm repeat groups →
Frequently Asked Questions
How do I prevent duplicate entries in an XLSForm repeat group?
Add a constraint to the question inside the repeat: count(../../member[member_id = current()]) = 1, using your own repeat and question names. It counts the instances holding the same answer, including the current one, so a count above 1 means a duplicate and the answer is refused.
Why does my duplicate check stop the form from finalizing?
The form probably uses not(selected(${all_ids}, .)) with a join() of every answer. That list contains the current answer too, so when Collect re-checks constraints at finalization each answer finds itself and fails. Replace it with count(../../repeat[question = current()]) = 1.
What does current() mean in an XLSForm constraint?
current() is the question the expression belongs to. Inside square brackets, a bare field name refers to each instance being filtered, while current() still points to the answer being checked. That difference is what lets the constraint compare one answer with all the others.
How do I hide choices already selected in an earlier repeat instance?
Add a calculate inside the repeat that joins the answers of the other instances, for example join(' ', ../../crop_rep[crop_pos != current()/../crop_pos]/crop), then set the select's choice_filter to not(selected(${crops_other}, name)). Used options disappear from the list in later instances.
Can I check for duplicates across nested repeat groups?
Yes. The number of ../ steps sets the scope. In a plot repeat inside a farmer repeat, count(../../plot[plot_no = current()]) checks within one farmer, and count(../../../farmer/plot[plot_no = current()]) checks every plot of every farmer in the form.
Does the duplicate check work in KoboToolbox and SurveyCTO?
Yes. Both use the same XPath functions as ODK, so the count() constraint and the choice filter work unchanged. In KoboToolbox Formbuilder, type the constraint as a manually entered validation criterion; in SurveyCTO and XLSForm uploads it goes in the constraint column.
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.