SurveyLoopr
Tips & Tricks
11 min read

Prevent Duplicate Answers in an XLSForm Repeat

Mehrab Ali
Mehrab AliPublished November 8, 2026

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.

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

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:

typenamelabelconstraintconstraint_message
begin repeatmemberHousehold member
textmember_nameName of household member
textmember_idNational ID number of ${member_name}count(../../member[member_id = current()]) = 1This ID is already recorded for another member.
end repeatmember
surveychoicessettings

Read the expression from the inside out:

  1. current() is the answer being checked, here this member's ID.
  2. ../../member walks up from the question: .. is this repeat instance, ../.. is the level that holds the repeat, and /member selects every instance of the repeat.
  3. [member_id = current()] keeps the instances whose ID equals this answer.
  4. count(...) = 1 passes 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:

typenamelabelconstraintconstraint_message
begin repeatsampleBlood sample
integerchild_lineLine number of the child on the roster
barcodetube_codeScan the barcode on the sample tubecount(../../sample[tube_code = current()]) = 1This tube was already scanned in this form. Scan a new tube.
end repeatsample
surveychoicessettings

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.

Warning:

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:

typenamelabelcalculationchoice_filter
begin repeatcrop_repCrop grown this season
calculatecrop_posposition(..)
calculatecrops_otherjoin(' ', ../../crop_rep[crop_pos != current()/../crop_pos]/crop)
select_one cropcropWhich crop is this?not(selected(${crops_other}, name))
integercrop_areaArea planted with this crop (decimals)
end repeatcrop_rep
surveychoicessettings
list_namenamelabel
crop1Rice
crop2Maize
crop3Wheat
crop4Jute
crop5Potato
crop-96Other
surveychoicessettings

How it works:

  • crop_pos stores the number of the current instance with position(..).
  • crops_other joins the crops of every other instance into a space-separated list, such as 1 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_filter keeps only the choices whose name is 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:

typenamelabelconstraintconstraint_message
begin repeatpersonHousehold member
textperson_nameName of household member
integerperson_ageAge of ${person_name} in completed yearscount(../../person[person_name = current()/../person_name and person_age = current()]) = 1A member with this name and age is already on the roster. Check for a double entry.
end repeatperson
surveychoicessettings

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:

typenamelabelconstraintconstraint_message
begin repeatfarmerFarmer
textfarmer_nameName of the farmer
begin repeatplotPlot
integerplot_noPlot number for ${farmer_name}count(../../plot[plot_no = current()]) = 1This farmer already has a plot with this number.
textgps_tagSurvey tag on the plot boundary markercount(../../../farmer/plot[gps_tag = current()]) = 1This marker tag is already recorded for another plot in this form.
end repeatplot
end repeatfarmer
surveychoicessettings
  • ../../plot stays inside the current farmer: .. is this plot, ../.. is this farmer.
  • ../../../farmer/plot goes 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 wroteWrite insteadWhat you will see
not(selected(${all_ids}, .)) with all_ids outside the repeatcount(../../member[member_id = current()]) = 1No error at upload; the form will not finalize and flags every member
count(../../member[member_id = current()]) = 0... = 1No error at upload; every answer is rejected, because the count includes the current instance
count(../../member[${member_id} = current()]) = 1count(../../member[member_id = current()]) = 1No error at upload; every answer is rejected, because ${member_id} becomes ../member_id inside the brackets and matches nothing
count(../member[member_id = current()]) = 1count(../../member[member_id = current()]) = 1No error at upload; every answer is rejected, because one ../ stays inside the current instance
join(' ', ${member_id}) in a calculate inside the repeatPut the calculate outside the repeat, or use count()No error at upload; the list holds only the current answer
count(...) == 1count(...) = 1contains 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:

PlatformWhere the duplicate check goes
ODK (XLSForm)constraint column of the question inside the repeat; filters in choice_filter
KoboToolbox FormbuilderQuestion settings, Validation Criteria, manually entered expression; use XLSForm upload for the choice filter
KoboToolbox (XLSForm upload)Same as ODK
SurveyCTOconstraint 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.

Tip:

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.

Build my first form free

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.