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:
survey— the questions, in the order they appear. Minimum columns:type,nameandlabel.choices— the option lists for multiple-choice questions. Minimum columns:list_name,nameandlabel.settings— title, identifier, version, default language and other form-wide settings (a single data row).
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:
| type | name | label | |
|---|---|---|---|
| 2 | text | interviewer | Name of the person applying the survey |
| 3 | integer | household_size | How many people live in the household? |
| 4 | select_one yes_no | has_water | Does the household have piped water? |
The question on row 4 uses the yes_no list, defined on the choices sheet:
| list_name | name | label | |
|---|---|---|---|
| 2 | yes_no | yes | Yes |
| 3 | yes_no | no | No |
And the settings sheet gives the form its identity:
| form_title | form_id | version | |
|---|---|---|---|
| 2 | Household Survey 2026 | household_2026 | 2026081101 |
Convert and you are done:
$ rxform household_survey.xlsx
household_survey.xml
3 · Every question type
Text and numbers
| type | name | label | hint | |
|---|---|---|---|---|
| 2 | text | notes_field | General notes | Free text |
| 3 | integer | age | Age (completed years) | Whole numbers only |
| 4 | decimal | income | Monthly income | Use a dot for cents |
| 5 | range | satisfaction | Satisfaction with transport | 0 = awful · 10 = great |
text— free text. For taller boxes useparameters=rows=5.integer/decimal— numbers, with the numeric keypad on the device.range— a slider; bounds viaparameters=start=0 end=10 step=1. Withappearance=ratingit becomes stars.
Dates and times
| type | name | label | appearance | |
|---|---|---|---|---|
| 2 | date | visit_date | Visit date | |
| 3 | date | moved_in | When did you move in? | month-year |
| 4 | time | start_time | Start time | |
| 5 | dateTime | follow_up | Schedule follow-up for |
Useful appearances for date: month-year, year and no-calendar.
Choices and lists
| type | name | label | appearance | |
|---|---|---|---|---|
| 2 | select_one education | education | Education of the household head | minimal |
| 3 | select_multiple services | services | Which services reach the street? | |
| 4 | rank priorities | priorities | Rank the neighborhood priorities |
select_one LIST— one answer.appearance:minimal(dropdown),likert,quick(auto-advance),columns…select_multiple LIST— several answers; stored space-separated, which is why choicenames cannot contain spaces.rank LIST— the respondent orders the options.select_one LIST or_other— appends an “Other” option plus an automatic “Specify other.” question.
Extra columns on the choices sheet are allowed and become choice data — the basis of cascading filters:
Cascading selects (choice_filter)
| list_name | name | label | uf | |
|---|---|---|---|---|
| 2 | states | pe | Pernambuco | |
| 3 | states | rs | Rio Grande do Sul | |
| 4 | cities | recife | Recife | pe |
| 5 | cities | olinda | Olinda | pe |
| 6 | cities | poa | Porto Alegre | rs |
| type | name | label | choice_filter | |
|---|---|---|---|---|
| 2 | select_one states | state | State | |
| 3 | select_one cities | city | City | state = ${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
| type | name | label | parameters | |
|---|---|---|---|---|
| 2 | geopoint | point | Household location | capture-accuracy=5 warning-accuracy=10 |
| 3 | geotrace | route | Route to the bus stop | |
| 4 | geoshape | plot | Plot outline |
geopoint captures a point (with target accuracy in meters via parameters); geotrace, a line; geoshape, a closed polygon.
Media and files
| type | name | label | appearance | parameters | |
|---|---|---|---|---|---|
| 2 | image | front_photo | Photo of the front | max-pixels=1024 | |
| 3 | image | signature | Respondent’s signature | signature | |
| 4 | audio | testimony | Record the testimony | quality=voice-only | |
| 5 | video | street_video | Video of the street | ||
| 6 | file | receipt | Attach the receipt (PDF) | ||
| 7 | barcode | meter_code | Water-meter barcode |
image with appearance = signature becomes a signature pad; draw, free drawing. max-pixels shrinks photos on the device.
Notes and acknowledgements
| type | name | label | |
|---|---|---|---|
| 2 | note | intro | Good morning! This survey takes ~15 minutes. Answers are confidential. |
| 3 | acknowledge | consent | The respondent agrees to participate |
note— displayed text, no answer. Accepts${references}: “Thank you, ${interviewer}!”. It is the only type that can omitname(rxform generates one).acknowledge— requires an explicit “ok” from the respondent.
Invisible fields
| type | name | calculation | |
|---|---|---|---|
| 2 | calculate | income_per_capita | ${income} div ${household_size} |
| 3 | hidden | sample_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
| type | name | label / trigger | |
|---|---|---|---|
| 2 | start | started_at | |
| 3 | end | ended_at | |
| 4 | today | today_date | |
| 5 | deviceid | device | |
| 6 | username | user | |
| 7 | audit | audit | |
| 8 | start-geopoint | start_location | |
| 9 | background-audio | recording | |
| 10 | background-geopoint | answer_location | trigger: ${has_water} |
start/end— timestamps for opening/finishing the form;today— the date;deviceid,username,phonenumber,email— device/account identification.audit— an audit trail of the filling session; withparameters=location-priority=balanced location-min-interval=60 location-max-age=120it also logs location during the interview.start-geopoint— silently captures location on open;background-audiorecords the interview audio;background-geopointcaptures location when thetriggerquestion is answered.
4 · Form logic
All logic uses ${name} to reference earlier answers.
relevant — skipping questions
| type | name | label | relevant | |
|---|---|---|---|---|
| 2 | select_one yes_no | works | Do you work? | |
| 3 | text | occupation | What is your occupation? | ${works} = 'yes' |
| 4 | note | adult_note | Module 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
| type | name | label | constraint | constraint_message | |
|---|---|---|---|---|---|
| 2 | integer | age | Age | . >= 0 and . <= 120 | Age must be between 0 and 120. |
| 3 | date | birth_date | Date 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
| type | name | label | required | required_message | default | |
|---|---|---|---|---|---|---|
| 2 | select_one yes_no | has_water | Piped water? | yes | This answer is required. | |
| 3 | date | visit_date | Visit date | today() | ||
| 4 | integer | tract | Census tract | 42 |
required=yes(or an expression) blocks advancing without an answer.defaulttakes a fixed value (42) or a dynamic expression (today(),${state}) evaluated when the form opens — including${last-saved#tract}to inherit the last submission’s value.read_only=yesshows the field without allowing edits.
trigger — recalculating on change
| type | name | label | trigger | calculation | |
|---|---|---|---|---|---|
| 2 | integer | household_size | How many residents? | ||
| 3 | integer | children | How 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
| type | name | label | appearance | relevant | |
|---|---|---|---|---|---|
| 2 | begin_group | water_module | Module: Sanitation | field-list | ${has_water} = 'yes' |
| 3 | select_one water_source | source | Water source | ||
| 4 | integer | days_without | Days without water this month | ||
| 5 | end_group |
appearance=field-listshows the whole group on one screen.table-list— for a run ofselect_ones sharing a list, renders a matrix (rows × columns).- A group’s
relevantapplies to everything inside it.
Repeats
| type | name | label | repeat_count | |
|---|---|---|---|---|
| 2 | begin_repeat | resident | Resident details | ${household_size} |
| 3 | text | name | Name | |
| 4 | integer | resident_age | Age | |
| 5 | end_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
| type | name | label | |
|---|---|---|---|
| 2 | begin loop over services | service_ratings | |
| 3 | select_one grades | grade | How do you rate the %(label)s service? |
| 4 | end 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
| type | name | label::Português (pt) | label::English (en) | hint::Português (pt) | |
|---|---|---|---|---|---|
| 2 | integer | moradores | Quantas 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 settings → default_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)
| appearance | applies to | effect |
|---|---|---|
| minimal | select_one/multiple | compact dropdown |
| quick | select_one | advances on selection |
| likert | select_one | horizontal Likert-style scale |
| columns / columns-n | selects | options in columns |
| autocomplete | select_one | search as you type |
| field-list | groups | whole group on one screen |
| table-list | groups | matrix of selects sharing a list |
| multiline | text | multi-line box |
| numbers / thousands-sep | text | numeric keypad · thousands separator |
| month-year · year · no-calendar | date | reduced precision · no calendar |
| signature · draw · annotate | image | signature · drawing · annotate a photo |
| map · quick map | select_one_from_file (geojson) | pick on a map |
| rating | range | stars |
| label · list-nolabel | selects | building blocks for manual matrices |
8 · Parameters (parameters)
The parameters column takes space-separated key=value pairs:
| type | parameters |
|---|---|
| range | start=0 end=10 step=1 |
| text | rows=5 |
| image | max-pixels=1024 |
| audio · background-audio | quality=voice-only | low | normal |
| geopoint | capture-accuracy=5 warning-accuracy=10 |
| selects | randomize=true seed=42 |
| select_*_from_file | value=coluna label=coluna |
| audit | location-priority=balanced location-min-interval=60 location-max-age=120 |
9 · The settings sheet
| column | what for |
|---|---|
| form_title | title shown in the app |
| form_id | unique form identifier on the server |
| version | version (use a date: 2026081101); the server manages updates by it |
| instance_name | name of each submission in listings — e.g. concat(${city}, '-', ${visit_date}) |
| default_language | starting language, e.g. English (en) |
| style | pages (one screen per group) · theme-grid |
| public_key | RSA key for end-to-end encrypted submissions |
| submission_url · auto_send · auto_delete | submission destination and policy |
| allow_choice_duplicates | allows repeated names in a list |
| clean_text_values | no preserves runs of spaces in cells (collapsed by default) |
| name · namespaces · attribute::x · prefix · delimiter · flat · omit_instanceID | advanced tweaks to the generated XML |
10 · External data and entities
Big lists in files
| type | name | label | choice_filter | |
|---|---|---|---|---|
| 2 | select_one_from_file cities.csv | city | City | state = ${state} |
| 3 | select_one_from_file tracts.geojson | tract | Tract (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()
| type | name | calculation | |
|---|---|---|---|
| 2 | calculate | tract_target | pulldata('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
- Misspelled type →
[sheet 'survey', row 3, column 'type'] unknown question type 'integr' — did you mean 'integer'? - Broken reference →
'${agge}' does not match… — did you mean 'age'? - Missing choice list → points at the row and suggests the closest list.
- Unclosed group → points at the
begin_grouprow and asks for theend_group. - Duplicate sibling names, names with spaces/accents, visible questions without a label, repeated options in a list — all with sheet, row, column and probable cause.
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
- KoboToolbox: upload the
.xlsxitself (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. - ODK Central: publish the
.xlsxor the generated.xml; attach external-list CSVs/GeoJSON. - 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