SurveyLoopr
Tips & Tricks
10 min read

XLSForm: Repeat a Group for Each Selected Option

Mehrab Ali
Mehrab AliPublished October 30, 2026

To repeat a group once for each option ticked in a select_multiple question, put count-selected(${crops}) in the repeat's repeat_count column, then read the option for each instance with selected-at(${crops}, position(..) - 1) in a calculate inside the repeat. Wrap that code in jr:choice-name(${crop_code}, '${crops}') to show its label, so the questions read "How much rice did you harvest?" instead of "crop 1". The syntax is the same in ODK and KoboToolbox; SurveyCTO has the same functions with two small differences, covered below.

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

How do I repeat a group for each selected option?

An agriculture survey asks which crops the household grew last season, then asks area, harvest and sales for each crop ticked. Three rows do the work: the repeat_count, a calculate for the code and a calculate for the label.

typenamelabelcalculationrepeat_count
select_multiple cropcropsWhich crops did the household grow last season?
begin repeatcrop_rptCrop detailscount-selected(${crops})
calculatecrop_codeselected-at(${crops}, position(..) - 1)
calculatecrop_namejr:choice-name(${crop_code}, '${crops}')
decimalcrop_areaHow many decimals of land were under ${crop_name}?
decimalharvest_kgHow many kg of ${crop_name} did you harvest?
select_one yes_nosold_anyDid you sell any ${crop_name}?
end repeatcrop_rpt
surveychoicessettings
list_namenamelabel
crop1Rice
crop2Wheat
crop3Maize
crop4Lentils
crop5Potatoes
yes_no1Yes
yes_no0No
surveychoicessettings

What each part does:

  1. count-selected(${crops}) returns how many options were ticked. A household that ticks Rice and Potatoes gets two repeat instances, with no "Add another?" prompt.
  2. position(..) is the number of the current repeat instance, counting from 1.
  3. selected-at(${crops}, n) returns the n-th ticked code, counting from 0. That is why the index is position(..) - 1: instance 1 reads item 0, which is the first option ticked.
  4. jr:choice-name(${crop_code}, '${crops}') turns code 5 into "Potatoes". The second argument is the select_multiple question in single quotes, not the list name.

The options come in the order they appear on the choices sheet, not the order the enumerator tapped them. Keep crop_code in the form even if you only show the label: it is the column that tells you which crop each row of the repeat export belongs to.

How do I ask the follow-ups for only some of the selected options?

Keep the repeat on every ticked option and put the questions that apply to some of them in a group with a relevant. This income module asks about every source, but the months question only for farming and livestock income. ODK skips the group for the other sources, and their instance has only the code, name and amount:

typenamelabelcalculationrepeat_countrelevant
select_multiple income_sourceincome_sourcesWhat were the household's sources of income in the last 12 months?
begin repeatincome_rptIncome sourcecount-selected(${income_sources})
calculatesource_codeselected-at(${income_sources}, position(..) - 1)
calculatesource_namejr:choice-name(${source_code}, '${income_sources}')
integersource_amountHow much did the household earn from ${source_name} in the last 12 months?
begin groupseasonalSeasonal income${source_code} = '1' or ${source_code} = '2'
integermonths_earningIn how many months did ${source_name} bring in money?
end groupseasonal
end repeatincome_rpt
surveychoicessettings
list_namenamelabel
income_source1Crop farming
income_source2Livestock
income_source3Salaried job
income_source4Daily labour
income_source5Remittances
surveychoicessettings

The codes are quoted ('1') because selected-at() returns text. A filter like this beats a second repeat that loops over a smaller list: the instance numbers stay lined up with the ticked options, and the data stays in one table.

Should I use one group per option instead of a repeat?

For a short, fixed list, often yes. A group per option with selected(${livestock}, '1') in relevant puts every answer in the main table, one column per animal, which is easier to analyse than a repeat table you have to merge back. The trade-off is one block of rows per option: past five or six options, or when the list changes between rounds, the repeat is less work to maintain.

typenamelabelrelevant
select_multiple animallivestockWhich animals does the household keep?
begin groupcattle_grpCattleselected(${livestock}, '1')
integercattle_countHow many cattle does the household own?
end groupcattle_grp
begin groupgoat_grpGoatsselected(${livestock}, '2')
integergoat_countHow many goats does the household own?
end groupgoat_grp
begin grouppoultry_grpPoultryselected(${livestock}, '3')
integerpoultry_countHow many chickens and ducks does the household own?
end grouppoultry_grp
surveychoicessettings
list_namenamelabel
animal1Cattle
animal2Goats
animal3Poultry
surveychoicessettings

For more ways to write selected() conditions, see skip logic on select_multiple questions.

How do I use the repeat's answers after the repeat?

Outside the repeat, ${harvest_kg} has one value per crop, so wrap it in a function. sum() adds them all. To read the answer for one crop, filter the repeat on the code it stored, ${crop_rpt}[crop_code = '1']/harvest_kg, which returns the rice harvest wherever rice sits in the list:

typenamelabelcalculationrepeat_count
select_multiple cropcropsWhich crops did the household grow last season?
begin repeatcrop_rptCrop detailscount-selected(${crops})
calculatecrop_codeselected-at(${crops}, position(..) - 1)
calculatecrop_namejr:choice-name(${crop_code}, '${crops}')
decimalharvest_kgHow many kg of ${crop_name} did you harvest?
end repeatcrop_rpt
calculatetotal_harvestsum(${harvest_kg})
calculaterice_harvestif(selected(${crops}, '1'), ${crop_rpt}[crop_code = '1']/harvest_kg, 0)
noteshow_totalsTotal harvest: ${total_harvest} kg. Rice: ${rice_harvest} kg.
surveychoicessettings
list_namenamelabel
crop1Rice
crop2Wheat
crop3Maize
surveychoicessettings

The if(selected(...)) keeps rice_harvest at 0 for households that grew no rice. To read an instance by its number instead of its code, use indexed-repeat().

Warning:

Repeat answers belong to the instance number, not to the option. If the enumerator fills the crop details and then goes back and unticks Rice, every instance moves up one crop: the wheat instance is now labelled Maize, while its answers stay where they were, and the last instance is deleted. Train enumerators to finish the select_multiple before moving on, and check the labels again if they change it.

Common mistakes and how to fix them

We ran each of these through pyxform and ODK Validate. The messages are what you will see in ODK Collect, Central or the XLSForm validator:

You wroteWrite insteadWhat you will see
selected-at(${crops}, position(..))selected-at(${crops}, position(..) - 1)No error. The first ticked option is skipped and the last instance has no option
count(${crops}) in repeat_countcount-selected(${crops})No error. The repeat runs once, however many options are ticked
jr:choice-name(${crop_code}, ${crops})Quote the question: '${crops}'error in evaluation of xpath function [choice-name] => ... Parse error in XPath path: [].
jr:choice-name(${crop_code}, '${crop}') (the list name)The select_multiple question's name: '${crops}'Could not find the name 'crop'.
selected-at(${crops})Add the index: selected-at(${crops}, position(..) - 1)cannot handle function 'selected-at'. Requires 2 arguments but 1 provided.
Selected-at(...)Lowercase: selected-at(...)cannot handle function 'Selected-at'
${crop_code} in a calculate after the repeatsum(), indexed-repeat() or a filter like ${crop_rpt}[crop_code = '1']/...This field is repeated: ... You may need to use the indexed-repeat() function to specify which value you want.

For other compile messages, see common XLSForm errors.

Does this work the same in ODK, KoboToolbox and SurveyCTO?

count-selected() and selected-at() exist on all three, and repeat_count is the same column. What differs:

PlatformWhat to know
ODK Collect and CentralEverything on this page as written: position(..) for the instance number and jr:choice-name() for the label
KoboToolbox (KoboCollect and Enketo web forms)Same ODK syntax. Upload the XLSForm; test the web form with two or more options ticked before deploying
SurveyCTOUse index() instead of position(..) for the instance number, and choice-label(${crops}, ${crop_code}) for the label. Skip logic goes in the relevance column

A form built with this pattern moves between ODK and KoboToolbox unchanged. For SurveyCTO, swap the two functions in the calculate rows.

Why loop over the ticked options

Asking about each crop, income source or illness by name keeps enumerators from reading "crop number 2" and guessing which one that was. The repeat also sizes itself: a household with one crop answers one block, a household with five answers five, and nobody scrolls past empty questions for crops they never grew. The stored code on every instance means the export can be joined back to the select_multiple without guesswork.

Tip:

You can describe the loop in plain language: "for each crop the household grew, ask area, harvest and whether they sold any, using the crop's name in the question". LooprAI writes the count-selected() and selected-at() rows, then compiles the form with pyxform before anything is saved.

Build my first form free

Next: How to use repeat groups in XLSForm →

Frequently Asked Questions

How do I repeat a group for each selected option in XLSForm?

Put count-selected(${crops}) in the repeat_count column of the begin repeat row. Inside the repeat, add a calculate with selected-at(${crops}, position(..) - 1) to get the option for that instance, and use it in the follow-up questions. ODK then creates one instance per ticked option.

Why does my repeat skip the first selected option?

selected-at() counts from 0 while position(..) counts from 1. Writing selected-at(${crops}, position(..)) makes instance 1 read the second option and leaves the last instance empty. Subtract one: selected-at(${crops}, position(..) - 1). ODK gives no error for this, so test with two or more options ticked.

How do I show the option's label inside the repeat?

Add a calculate with jr:choice-name(${crop_code}, '${crops}'), where crop_code holds the selected-at() result and crops is the select_multiple question in single quotes. Then put ${crop_name} in the labels, for example "How many kg of ${crop_name} did you harvest?".

What happens if the enumerator changes the selection later?

Repeat answers stay with the instance number. Unticking an option shifts every later option up one instance, so answers given for one crop are now labelled with the next, and the last instance is deleted. Ask enumerators to finish the select_multiple first and recheck the labels after any change.

How do I get one option's answer after the repeat?

Store the code in each instance, then filter the repeat on it: ${crop_rpt}[crop_code = '1']/harvest_kg returns the rice harvest wherever rice sits in the list. Wrap it in if(selected(${crops}, '1'), ..., 0) so households without that option get 0. Use sum(${harvest_kg}) for a total.

Does this work in KoboToolbox and SurveyCTO?

KoboToolbox uses the same ODK syntax, including position(..) and jr:choice-name(). SurveyCTO has count-selected() and selected-at() too, but uses index() for the instance number and choice-label(${crops}, ${crop_code}) for the label. The repeat_count column works the same on all three.

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.