complete guide · EN

Building a questionnaire
from scratch, cell by cell.

XLSForm is the standard that turns an ordinary spreadsheet into a full digital form — with skip logic, validation, GPS, photos and multiple languages — ready to run on ODK Collect, KoboToolbox or Enketo. This guide builds a household survey step by step and documents every question type and setting, with examples you can copy and convert with rxform.

1 · Anatomy of an XLSForm

An XLSForm is an .xlsx file (or .xls/.ods) with up to three main sheets:

Golden rules for the name column (questions, groups and choices alike):

Names start with a letter or _ and contain only letters, digits, -, _ and . — no spaces or accents. They become the column names in your dataset, so prefer monthly_income over Monthly Income (US$). The label, on the other hand, is free text: accents, emoji, whatever you need.

2 · Your first form

The smallest useful form has three rows on the survey sheet:

survey
typenamelabel
2textinterviewerName of the person applying the survey
3integerhousehold_sizeHow many people live in the household?
4select_one yes_nohas_waterDoes the household have piped water?

The question on row 4 uses the yes_no list, defined on the choices sheet:

choices
list_namenamelabel
2yes_noyesYes
3yes_nonoNo

And the settings sheet gives the form its identity:

settings
form_titleform_idversion
2Household Survey 2026household_20262026081101

Convert and you are done:

$ rxform household_survey.xlsx
household_survey.xml

3 · Every question type

Text and numbers

survey
typenamelabelhint
2textnotes_fieldGeneral notesFree text
3integerageAge (completed years)Whole numbers only
4decimalincomeMonthly incomeUse a dot for cents
5rangesatisfactionSatisfaction with transport0 = awful · 10 = great

Dates and times

survey
typenamelabelappearance
2datevisit_dateVisit date
3datemoved_inWhen did you move in?month-year
4timestart_timeStart time
5dateTimefollow_upSchedule follow-up for

Useful appearances for date: month-year, year and no-calendar.

Choices and lists

survey
typenamelabelappearance
2select_one educationeducationEducation of the household headminimal
3select_multiple servicesservicesWhich services reach the street?
4rank prioritiesprioritiesRank the neighborhood priorities

Extra columns on the choices sheet are allowed and become choice data — the basis of cascading filters:

Cascading selects (choice_filter)

choices
list_namenamelabeluf
2statespePernambuco
3statesrsRio Grande do Sul
4citiesrecifeRecifepe
5citiesolindaOlindape
6citiespoaPorto Alegrers
survey
typenamelabelchoice_filter
2select_one statesstateState
3select_one citiescityCitystate = ${state}

In the filter, list columns appear by name (state) and earlier answers as ${state}. To shuffle options: parameters = randomize=true (add seed=42 for a reproducible order).

Location

survey
typenamelabelparameters
2geopointpointHousehold locationcapture-accuracy=5 warning-accuracy=10
3geotracerouteRoute to the bus stop
4geoshapeplotPlot outline

geopoint captures a point (with target accuracy in meters via parameters); geotrace, a line; geoshape, a closed polygon.

Media and files

survey
typenamelabelappearanceparameters
2imagefront_photoPhoto of the frontmax-pixels=1024
3imagesignatureRespondent’s signaturesignature
4audiotestimonyRecord the testimonyquality=voice-only
5videostreet_videoVideo of the street
6filereceiptAttach the receipt (PDF)
7barcodemeter_codeWater-meter barcode

image with appearance = signature becomes a signature pad; draw, free drawing. max-pixels shrinks photos on the device.

Notes and acknowledgements

survey
typenamelabel
2noteintroGood morning! This survey takes ~15 minutes. Answers are confidential.
3acknowledgeconsentThe respondent agrees to participate

Invisible fields

survey
typenamecalculation
2calculateincome_per_capita${income} div ${household_size}
3hiddensample_version

calculate computes a value with XPath (functions like if(), concat(), selected(), count(), round()…); hidden holds a value fillable via default or integrations.

Metadata — collected on its own

survey
typenamelabel / trigger
2startstarted_at
3endended_at
4todaytoday_date
5deviceiddevice
6usernameuser
7auditaudit
8start-geopointstart_location
9background-audiorecording
10background-geopointanswer_locationtrigger: ${has_water}

4 · Form logic

All logic uses ${name} to reference earlier answers.

relevant — skipping questions

survey
typenamelabelrelevant
2select_one yes_noworksDo you work?
3textoccupationWhat is your occupation?${works} = 'yes'
4noteadult_noteModule applicable to adults.${age} >= 18

The question only appears when the expression is true. Combine conditions with and/or; for multi-selects use selected(${services}, 'water').

constraint — validating answers

survey
typenamelabelconstraintconstraint_message
2integerageAge. >= 0 and . <= 120Age must be between 0 and 120.
3datebirth_dateDate of birth. <= today()The date cannot be in the future.

The dot . is the answer itself. The message shows when the rule fails.

required, default, read_only

survey
typenamelabelrequiredrequired_messagedefault
2select_one yes_nohas_waterPiped water?yesThis answer is required.
3datevisit_dateVisit datetoday()
4integertractCensus tract42

trigger — recalculating on change

survey
typenamelabeltriggercalculation
2integerhousehold_sizeHow many residents?
3integerchildrenHow many children?${household_size}

With trigger, the field is (re)set whenever the referenced question changes — here, clearing children when household_size is edited. If a calculation is present, it runs at that moment (instead of continuously).

5 · Groups, repeats and loops

Groups

survey
typenamelabelappearancerelevant
2begin_groupwater_moduleModule: Sanitationfield-list${has_water} = 'yes'
3select_one water_sourcesourceWater source
4integerdays_withoutDays without water this month
5end_group

Repeats

survey
typenamelabelrepeat_count
2begin_repeatresidentResident details${household_size}
3textnameName
4integerresident_ageAge
5end_repeat

The block repeats once per resident. Without repeat_count the interviewer adds repetitions manually; with an expression (or fixed number) the count is automatic. Inside a repeat, use position(..) for the current index and indexed-repeat() to read values from another repetition.

Loops over a list

survey
typenamelabel
2begin loop over servicesservice_ratings
3select_one gradesgradeHow do you rate the %(label)s service?
4end loop

Generates one block per option of the services list, substituting %(label)s and %(name)s with each option’s label/name — one question “How do you rate the Water service?”, another “…the Sewage service?”, and so on.

6 · Multiple languages

survey
typenamelabel::Português (pt)label::English (en)hint::Português (pt)
2integermoradoresQuantas pessoas moram aqui?How many people live here?Count every resident

Just suffix the translatable columns with ::Language (code): it works for label, hint, guidance_hint, constraint_message, required_message, image, audio and video — on both the survey and choices sheets. Set the starting language in settingsdefault_language = English (en). The app gains a language menu.

Per-question media: the image/audio/video columns attach files (e.g. answer_card.jpg) shipped with the form.

7 · Appearances (appearance)

reference
appearanceapplies toeffect
minimalselect_one/multiplecompact dropdown
quickselect_oneadvances on selection
likertselect_onehorizontal Likert-style scale
columns / columns-nselectsoptions in columns
autocompleteselect_onesearch as you type
field-listgroupswhole group on one screen
table-listgroupsmatrix of selects sharing a list
multilinetextmulti-line box
numbers / thousands-septextnumeric keypad · thousands separator
month-year · year · no-calendardatereduced precision · no calendar
signature · draw · annotateimagesignature · drawing · annotate a photo
map · quick mapselect_one_from_file (geojson)pick on a map
ratingrangestars
label · list-nolabelselectsbuilding blocks for manual matrices

8 · Parameters (parameters)

The parameters column takes space-separated key=value pairs:

reference
typeparameters
rangestart=0 end=10 step=1
textrows=5
imagemax-pixels=1024
audio · background-audioquality=voice-only | low | normal
geopointcapture-accuracy=5 warning-accuracy=10
selectsrandomize=true seed=42
select_*_from_filevalue=coluna label=coluna
auditlocation-priority=balanced location-min-interval=60 location-max-age=120

9 · The settings sheet

reference
columnwhat for
form_titletitle shown in the app
form_idunique form identifier on the server
versionversion (use a date: 2026081101); the server manages updates by it
instance_namename of each submission in listings — e.g. concat(${city}, '-', ${visit_date})
default_languagestarting language, e.g. English (en)
stylepages (one screen per group) · theme-grid
public_keyRSA key for end-to-end encrypted submissions
submission_url · auto_send · auto_deletesubmission destination and policy
allow_choice_duplicatesallows repeated names in a list
clean_text_valuesno preserves runs of spaces in cells (collapsed by default)
name · namespaces · attribute::x · prefix · delimiter · flat · omit_instanceIDadvanced tweaks to the generated XML

10 · External data and entities

Big lists in files

survey
typenamelabelchoice_filter
2select_one_from_file cities.csvcityCitystate = ${state}
3select_one_from_file tracts.geojsontractTract (on the map)

The CSV needs name and label columns (or point at others with parameters = value=code label=description); GeoJSON uses id/title and enables appearance = map. The file is uploaded to the server with the form.

Lookups with pulldata()

survey
typenamecalculation
2calculatetract_targetpulldata('targets', 'target', 'tract', ${tract})

Looks up the target column in targets.csv for the row where tract = the answer. There is also ${last-saved#field} (the last saved submission’s value) and the entities sheet, which lets a form create and update registries shared across forms (e.g. register households on one visit and find them again on the next) — with save_to on questions and create_if/update_if/label on the sheet.

11 · Common errors — and how rxform warns you

12 · Convert and publish

# converter
$ rxform household_survey.xlsx
household_survey.xml

# conferir erros de autoria é só rodar — a mensagem aponta a célula
$ rxform pesquisa_domiciliar.xlsx --stdout > /dev/null
  1. KoboToolbox: upload the .xlsx itself (Kobo converts server-side with pyxform — rxform produces exactly the same XML, so it doubles as instant local validation) or deploy the XML via API.
  2. ODK Central: publish the .xlsx or the generated .xml; attach external-list CSVs/GeoJSON.
  3. Enketo: published forms automatically get a web link.

Recommended flow: keep the .xlsx under version control, run rxform in CI to validate every change (conversion fails with a precise message when something breaks), and publish from the validated version.

guide · rxform — back to home · official xlsform.org reference