Collecting data with forms¶
Forms are how you collect responses from participants. Define form fields on your page, and uproot handles rendering, validation, and storage automatically.
The fields dictionary¶
Define fields as a dictionary on your page class:
class Survey(Page):
fields = dict(
age=IntegerField(label="How old are you?", min=18, max=100),
feedback=StringField(label="Any comments?", optional=True),
)
Field names become attributes on the player object. After submission, access the data as player.age and player.feedback.
Available field types¶
Text fields¶
StringField: Single-line text input
TextAreaField: Multi-line text input
EmailField: HTML email input
Numeric fields¶
IntegerField: Whole numbers
DecimalField: Decimal numbers (use this for money)
FloatField: Floating-point numbers (same options as DecimalField, stored as float)
Selection fields¶
RadioField: Choose one option with radio buttons
SelectField: Dropdown selection
country=SelectField(
label="Country",
choices=[
("us", "United States"),
("uk", "United Kingdom"),
("de", "Germany"),
],
)
The dropdown starts with an empty option (“—”). So a participant who skips the question does not submit the first country by accident. If the field is required, the empty option is rejected with an error message. If it is optional, the field is saved as None.
BooleanField: Checkbox
BoundedChoiceField: Multi-select checkboxes with optional min/max counts
topics=BoundedChoiceField(
label="Select your preferred topics",
choices=["Economics", "Psychology", "Sociology"],
min=1,
max=3,
)
Scales and sliders¶
LikertField: Rating scale with labeled endpoints
satisfaction=LikertField(
label="How satisfied are you?",
label_min="Very unsatisfied",
label_max="Very satisfied",
min=1,
max=7,
)
DecimalRangeField: Slider input
To render several Likert items as one matrix (shared scale, one row per question)
likert_grid() is the responsive version: below a width threshold (default 992px, or pass breakpoint=), each row becomes its own column of radio buttons.
Unlike field(), these two macros do not show validation errors themselves. Add {{ errors() }} as in the example so participants can see why a required response was rejected.
See likert_matrix in the input_elements example
Other fields¶
DateField: Date picker
FileField: File upload (see File uploads)
IBANField** and **BICField: Bank identifiers (require the optional iban extra, installed with uv add 'uproot-science[iban]<1'); see Form fields
Field options¶
Most fields support these common options:
| Option | Description | Default |
|---|---|---|
label |
Question text shown to participant | "" |
optional |
Allow empty responses where supported | False |
description |
Help text below the field | None |
default |
Pre-filled value | None |
Numeric constraints¶
IntegerField and DecimalField support min and max:
donation=IntegerField(
label="How much do you donate?",
min=0,
max=100,
description="Enter a value between 0 and 100",
)
Choice formats¶
For RadioField and SelectField, choices can be:
# List of (value, label) tuples
choices=[(1, "Option A"), (2, "Option B")]
# Dictionary
choices={"a": "Option A", "b": "Option B"}
# Simple boolean (RadioField only)
choices=[(True, "Yes"), (False, "No")]
The value is what gets stored; the label is what participants see.
Radio button layout¶
Control radio button arrangement with layout:
# Vertical (default)
choice=RadioField(label="Pick one", choices=[...], layout="vertical")
# Horizontal
choice=RadioField(label="Pick one", choices=[...], layout="horizontal")
Rendering fields in templates¶
Fields render automatically when you extend the base template:
To render fields individually with custom layout:
{% block main %}
<div class="row">
<div class="col-6">{{ field(form.age) }}</div>
<div class="col-6">{{ field(form.income) }}</div>
</div>
{{ field(form.comments) }}
{% endblock %}
field() already shows that field’s validation errors. Use {{ errors() }} only if you render inputs yourself and still need the error messages.
Form validation¶
Built-in validation¶
Fields validate automatically based on their type and options:
- Required fields must have a value (unless
optional=True) - Numeric fields check
minandmaxbounds - Email fields use an email input element; add custom validation if you need strict server-side email checks
Invalid submissions show error messages and keep the participant on the page.
Custom validation¶
Add a validate method for custom validation logic:
class Allocation(Page):
fields = dict(
give_a=IntegerField(label="Give to A", min=0, max=100),
give_b=IntegerField(label="Give to B", min=0, max=100),
)
@classmethod
def validate(page, player, data):
if data["give_a"] + data["give_b"] > 100:
return "Total cannot exceed 100"
The data parameter contains submitted values as a dictionary. Return a string (or list of strings) if there is an error. Return nothing if validation passes.
Async validation¶
The validate method can be async for database lookups or external checks:
@classmethod
async def validate(page, player, data):
if await username_taken(data["username"]):
return "Username already exists"
Dynamic fields¶
Generate fields based on player state using the fields method:
class DynamicSurvey(Page):
@classmethod
def fields(page, player):
choices = [
(i, f"Option {i}")
for i in range(1, player.num_options + 1)
]
return dict(
selection=SelectField(label="Choose:", choices=choices),
)
This is useful for:
- Personalizing questions based on prior responses
- Showing different options per experimental condition
- Building adaptive surveys
- Setting field constraints based on other players’ choices (in multiplayer games)
See dynamic fields in the trust_game example
Stealth fields¶
Some submitted values should not be saved as they are: a file upload, a code that unlocks the next page, or bank details that belong in separate payment records. Stealth fields are validated like any other field, but uproot does not save them. Instead, it passes them to your handle_stealth_fields method, which decides what to do with them.
Defining stealth fields¶
Mark fields as stealth and provide a handler:
class UploadPage(Page):
fields = dict(
participant_name=StringField(label="Your name"),
photo=FileField(label="Upload a photo"),
)
stealth_fields = ["photo"]
@classmethod
async def handle_stealth_fields(page, player, data):
photo = data["photo"]
if photo is not None:
content = await photo.read()
player.photo_size = len(content)
player.photo_name = photo.filename
The participant_name field saves automatically to player.participant_name.
player.name is reserved for uproot’s generated player name. The photo field
is available in the data dictionary passed to handle_stealth_fields instead.
data contains all fields of the page, not only the stealth fields.
Rejecting a submission¶
Return an error message (or a list of messages) to reject the submission. The participant stays on the page and sees the message:
def new_player(player):
player.wrong_codes = 0
class Gate(Page):
fields = dict(
nickname=StringField(label="Choose a nickname"),
code=StringField(label="Enter the code from the experimenter"),
)
stealth_fields = ["code"]
@classmethod
def handle_stealth_fields(page, player, data):
if data["code"] != "4711":
player.wrong_codes += 1
return "This code is not correct. Please ask the experimenter."
A rejected submission saves nothing, just as when validate rejects it: neither the stealth fields nor the page’s other fields. Above, nickname is only saved once the code is correct. Changes that your handler makes itself, like counting wrong_codes, are kept.
The handler runs after all validation has passed and before uproot saves the page’s other fields. before_form_save only runs if the handler accepts the submission.
Stealth fields on pages with a timeout
Once a page timeout has expired, a rejection no longer keeps the participant on the page. Nothing is saved, and the participant moves on.
Dynamic stealth fields¶
Use a method instead of a list for dynamic stealth field names:
@classmethod
def stealth_fields(page, player):
return ["response"] if player.condition == "special" else []
Sensitive data¶
Stealth fields keep sensitive data like payment information out of the main database. This matters because uproot’s database never forgets a value. Here, the IBAN goes to a separate CSV file that you can handle (and delete) independently of your experimental data:
class PaymentForm(Page):
fields = dict(
iban=IBANField(label="Your IBAN"),
rating=DecimalRangeField(label="Rate this experiment", min=0, max=5),
)
stealth_fields = ["iban"]
@classmethod
def handle_stealth_fields(page, player, data):
append_to_csv(
"payments.csv",
{"session": player.session.name, "player": player.name, "iban": data["iban"]},
)
append_to_csv adds one row to the file, creating it if necessary.
Stealth fields still reach the server
A stealth field is not saved in the database, but its value is still sent to your server. Whatever your handler does with it is up to you. For example, print(data["iban"]) writes the IBAN into your server’s log. If the server should never see a value, do not submit it at all; see Personal data stays in the log.
See the payment_data example · quiz example
Accessing submitted data¶
After form submission, field values are automatically saved to the player:
class Results(Page):
@classmethod
def templatevars(page, player):
return dict(
their_age=player.age, # From IntegerField
their_choice=player.choice, # From RadioField
)
Values are stored with their proper types: integers stay integers, Booleans stay Booleans.
Complete example¶
Here is a complete page with multiple field types and validation:
class Questionnaire(Page):
fields = dict(
age=IntegerField(
label="What is your age?",
min=18,
max=100,
),
gender=RadioField(
label="What is your gender?",
choices=[
("m", "Male"),
("f", "Female"),
("o", "Other"),
("na", "Prefer not to say"),
],
),
risk=LikertField(
label="How willing are you to take risks?",
label_min="Not at all willing",
label_max="Very willing",
min=0,
max=10,
),
income=DecimalField(
label="Annual income (in thousands)",
min=0,
max=10000,
optional=True,
description="This field is optional",
),
)
@classmethod
def validate(page, player, data):
if data["age"] < 18:
return "You must be at least 18 to participate"