XLSForm: Repeat a Group for Each Selected Option
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.
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.
| type | name | label | calculation | repeat_count |
|---|---|---|---|---|
| select_multiple crop | crops | Which crops did the household grow last season? | ||
| begin repeat | crop_rpt | Crop details | count-selected(${crops}) | |
| calculate | crop_code | selected-at(${crops}, position(..) - 1) | ||
| calculate | crop_name | jr:choice-name(${crop_code}, '${crops}') | ||
| decimal | crop_area | How many decimals of land were under ${crop_name}? | ||
| decimal | harvest_kg | How many kg of ${crop_name} did you harvest? | ||
| select_one yes_no | sold_any | Did you sell any ${crop_name}? | ||
| end repeat | crop_rpt |
| list_name | name | label |
|---|---|---|
| crop | 1 | Rice |
| crop | 2 | Wheat |
| crop | 3 | Maize |
| crop | 4 | Lentils |
| crop | 5 | Potatoes |
| yes_no | 1 | Yes |
| yes_no | 0 | No |
What each part does:
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.position(..)is the number of the current repeat instance, counting from 1.selected-at(${crops}, n)returns the n-th ticked code, counting from 0. That is why the index isposition(..) - 1: instance 1 reads item 0, which is the first option ticked.jr:choice-name(${crop_code}, '${crops}')turns code5into "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:
| type | name | label | calculation | repeat_count | relevant |
|---|---|---|---|---|---|
| select_multiple income_source | income_sources | What were the household's sources of income in the last 12 months? | |||
| begin repeat | income_rpt | Income source | count-selected(${income_sources}) | ||
| calculate | source_code | selected-at(${income_sources}, position(..) - 1) | |||
| calculate | source_name | jr:choice-name(${source_code}, '${income_sources}') | |||
| integer | source_amount | How much did the household earn from ${source_name} in the last 12 months? | |||
| begin group | seasonal | Seasonal income | ${source_code} = '1' or ${source_code} = '2' | ||
| integer | months_earning | In how many months did ${source_name} bring in money? | |||
| end group | seasonal | ||||
| end repeat | income_rpt |
| list_name | name | label |
|---|---|---|
| income_source | 1 | Crop farming |
| income_source | 2 | Livestock |
| income_source | 3 | Salaried job |
| income_source | 4 | Daily labour |
| income_source | 5 | Remittances |
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.
| type | name | label | relevant |
|---|---|---|---|
| select_multiple animal | livestock | Which animals does the household keep? | |
| begin group | cattle_grp | Cattle | selected(${livestock}, '1') |
| integer | cattle_count | How many cattle does the household own? | |
| end group | cattle_grp | ||
| begin group | goat_grp | Goats | selected(${livestock}, '2') |
| integer | goat_count | How many goats does the household own? | |
| end group | goat_grp | ||
| begin group | poultry_grp | Poultry | selected(${livestock}, '3') |
| integer | poultry_count | How many chickens and ducks does the household own? | |
| end group | poultry_grp |
| list_name | name | label |
|---|---|---|
| animal | 1 | Cattle |
| animal | 2 | Goats |
| animal | 3 | Poultry |
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:
| type | name | label | calculation | repeat_count |
|---|---|---|---|---|
| select_multiple crop | crops | Which crops did the household grow last season? | ||
| begin repeat | crop_rpt | Crop details | count-selected(${crops}) | |
| calculate | crop_code | selected-at(${crops}, position(..) - 1) | ||
| calculate | crop_name | jr:choice-name(${crop_code}, '${crops}') | ||
| decimal | harvest_kg | How many kg of ${crop_name} did you harvest? | ||
| end repeat | crop_rpt | |||
| calculate | total_harvest | sum(${harvest_kg}) | ||
| calculate | rice_harvest | if(selected(${crops}, '1'), ${crop_rpt}[crop_code = '1']/harvest_kg, 0) | ||
| note | show_totals | Total harvest: ${total_harvest} kg. Rice: ${rice_harvest} kg. |
| list_name | name | label |
|---|---|---|
| crop | 1 | Rice |
| crop | 2 | Wheat |
| crop | 3 | Maize |
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().
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 wrote | Write instead | What 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_count | count-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 repeat | sum(), 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:
| Platform | What to know |
|---|---|
| ODK Collect and Central | Everything 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 |
| SurveyCTO | Use 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.
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.
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.