Skip to content

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

participant_name=StringField(label="Your name")

TextAreaField: Multi-line text input

comments=TextAreaField(label="Additional comments", optional=True)

EmailField: HTML email input

email=EmailField(label="Email address")

Numeric fields

IntegerField: Whole numbers

age=IntegerField(label="Age", min=0, max=120)

DecimalField: Decimal numbers (use this for money)

amount=DecimalField(label="Amount to contribute", min=0.0, max=100.0)

FloatField: Floating-point numbers (same options as DecimalField, stored as float)

reaction_time=FloatField(label="Reaction time (seconds)", min=0.0)

Selection fields

RadioField: Choose one option with radio buttons

choice=RadioField(
    label="Do you agree?",
    choices=[(True, "Yes"), (False, "No")],
)

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

consent=BooleanField(label="I agree to participate")

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

confidence=DecimalRangeField(
    label="Confidence level",
    min=0,
    max=100,
    step=1,
)

To render several Likert items as one matrix (shared scale, one row per question)

{{ likert_matrix(form.item1, form.item2, form.item3) }}
{{ errors() }}

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

birthdate=DateField(label="Date of birth")

FileField: File upload (see File uploads)

document=FileField(label="Upload your document")

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:

{% extends "Base.html" %}

{% block main %}
<h1>Survey</h1>
{{ fields() }}
{% endblock %}

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 min and max bounds
  • 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"

See forms in the quiz example