Chapter 5

Reading Requests

ctx.BindJSON with automatic Validate, the ctx.Read family, query and path accessors, forms, file uploads, cookies, body limits and the validator hook.

Every handler receives an iris.Context, and half of its surface exists to answer one question: what did the client send? This chapter covers that half: request metadata, typed path and query accessors, body decoding with the recommended ctx.BindJSON path and the lower-level ctx.Read* family, pagination input, forms and file uploads, cookies, body size limits, and the application-level validation hook. Writing responses is the subject of Chapter 6.

Background

An HTTP request is a small, structured text message. Its first line names a method and a path: the method says what kind of action the client intends (GET reads, POST creates, PUT replaces, DELETE removes), and the path says which resource the action targets. Headers come next, then optionally a body. Data can arrive in four distinct places, and each place has a job:

  • The path identifies the resource. In /users/42, the 42 is a path parameter (part of the URL itself), so it is always present and always text until you convert it.
  • The query string is everything after the ?. In /search?q=iris&page=2, q and page are query parameters: they refine a request (filters, sorting, page numbers) without changing which resource it targets.
  • Headers are key-value metadata about the request: who the client is (User-Agent), what credentials it carries (Authorization), what format the body is in (Content-Type).
  • The body is the payload itself: the JSON document to store, the form fields, the uploaded file. Only some methods carry one; a GET usually does not.

The Content-Type header tells the server how to interpret the body’s bytes, using a media type (also called a MIME type) such as application/json or text/html. JSON is the dominant one for APIs: a plain-text format defined by RFC 8259, with exactly six shapes: objects ({}), arrays ([]), strings, numbers, booleans and null. Because it is plain text, every language can produce and read it, which is why it became the common tongue between web services.

HTML forms have their own two media types. A simple form posts application/x-www-form-urlencoded: field names and values joined into one name=value&... string, essentially a query string moved into the body. A form with a file input posts multipart/form-data: the body is split into parts separated by a boundary marker, each part carrying its own headers, so text fields and raw file bytes travel in one request.

One rule shapes everything in this chapter: never trust the client. Every piece of a request (path, query, headers, body, cookies, even the declared Content-Type) is chosen by the sender, and the sender is not always a well-behaved browser. Anyone can send any bytes with a one-line curl command, so client-side checks (a form’s required attribute, a JavaScript validator) are conveniences, not guarantees. Server-side validation is the real gate, and it is what this chapter’s reading helpers build in: ctx.BindJSON decodes and validates before your logic runs, the typed accessors refuse malformed numbers, and body limits stop a client from streaming gigabytes at your decoder. The problem that disappears from your code is manual string parsing and defensive if ladders: handlers receive typed, validated values or never run at all.

Request Metadata

The basics (method, path, host) are one call away:

Go
package main

import "github.com/kataras/iris/v14"

func main() {
    app := iris.New()
    app.Get("/info", info)
    app.Listen(":8080")
}

func info(ctx iris.Context) {
    ctx.JSON(iris.Map{
        "method":    ctx.Method(),         // "GET"
        "path":      ctx.Path(),           // "/info"
        "uri":       ctx.FullRequestURI(), // "http://localhost:8080/info"
        "scheme":    ctx.Scheme(),         // "http://"
        "host":      ctx.Host(),           // "localhost:8080"
        "hostname":  ctx.Hostname(),       // "localhost"
        "subdomain": ctx.Subdomain(),      // "" unless e.g. api.example.com
        "ip":        ctx.RemoteAddr(),
    })
}

When you need the raw *http.Request, ctx.Request() returns it.

The client address

ctx.RemoteAddr() returns the client IP. By default it parses only the transport-level Request.RemoteAddr: proxy headers such as X-Forwarded-For are ignored, because any client can forge them. Enable them only when your server sits behind a proxy you control:

Go
app := iris.New(iris.Options{
    Context: iris.ContextOptions{
        RemoteAddrHeaders: []string{"X-Forwarded-For", "X-Real-IP"},
    },
})

With RemoteAddrHeaders set, RemoteAddr scans the listed headers in order and returns the first public IP; candidates inside the private ranges listed in RemoteAddrPrivateSubnets are skipped unless you also set RemoteAddrHeadersForce. The related ctx.Addr() returns the first candidate even when it belongs to a private network. Never base security decisions solely on a client-reported address.

Request headers

ctx.Header(name) reads a request header. The response counterpart is ctx.ResponseHeader(name), covered in Chapter 6:

Go
auth := ctx.Header("Authorization")
agent := ctx.Header("User-Agent")

One deliberate quirk: when the client repeats a header, ctx.Header returns all values joined by ", " instead of only the first (which is what http.Header.Get would do). Code that compares the result against a secret therefore rejects a request carrying two copies of the header rather than silently honoring the first one.

Headers also bind to a struct through the header field tag:

Go
type ClientInfo struct {
    UserAgent string `header:"User-Agent"`
    RequestID string `header:"X-Request-ID"`
}

var info ClientInfo
if err := ctx.ReadHeader(&info); err != nil {
    errors.InvalidArgument.Err(ctx, err)
    return
}

Content type of the request

ctx.ContentType() parses the request’s Content-Type header into a structured value; ctx.MediaType() returns just the media type string, and ctx.Charset() and ctx.ContentLength() complete the picture:

Go
ct := ctx.ContentType()   // ContentType{MediaType, Charset, Boundary, ...}
mt := ctx.MediaType()     // e.g. "application/json"
cs := ctx.Charset()       // e.g. "utf-8", "" when absent
n := ctx.ContentLength()  // int64, 0 when the header is missing

These are request-side methods. The response equivalents are ctx.ResponseContentType() and ctx.ResponseMediaType().

Path and Query Parameters

Path parameters

Dynamic route segments ({id:int64}, {slug}; see Chapter 4 for the macro types) are read through ctx.Params(). Accessors return the value directly with a type-specific default on a missing key or failed conversion; there are no (value, error) tuples:

Go
app.Get("/users/{id:int64}/posts/{slug}", func(ctx iris.Context) {
    id := ctx.Params().GetInt64("id")      // int64, -1 on failure
    slug := ctx.Params().GetString("slug") // string, "" on failure
    // ...
})
AccessorFailure default
GetString""
GetInt, GetInt64-1
GetUint, GetUint640
GetFloat64-1
GetBoolfalse
GetUUIDzero uuid.UUID (equal to uuid.Nil())
GetDuration0
GetTimezero time.Time
GetIPzero netip.Addr (IsValid() false)

When the default is ambiguous (is -1 a real value or a miss?), use Lookup, which reports presence, or Exists:

Go
if raw, ok := ctx.Params().Lookup("id"); ok {
    _ = raw // the stored value, typed by the route macro.
}

A request whose typed parameter does not match the macro (say /users/abc against {id:int64}, or a malformed {id:uuid}) never reaches your handler; applications built with iris.NewBuilder render a 400 INVALID_ARGUMENT (“invalid path parameter”) response automatically.

The context has a generic method that writes the error response for you (404 when the parameter is missing, 400 when it is not of type T), so the handler only handles the happy path:

Go
app.Get("/bookmarks/{id:int64}", func(ctx iris.Context) {
    id, ok := ctx.BindParam[int64]("id")
    if !ok {
        return // error response already sent.
    }
    // ...
})

To bind all path parameters at once, ctx.ReadParams(&dst) decodes into a struct using the param field tag; ctx.BindParams[S]() does the same and writes the error response itself, returning (S, bool).

T is the type the macro stored, not the type you would like: {id:int64} stores an int64, and {id:uuid} stores a parsed uuid.UUID, so a uuid parameter is read as ctx.BindParam[uuid.UUID]("id") or ctx.Params().GetUUID("id"). Asking for string there answers 400, because the assertion fails. ctx.Params().GetString("id") still works and returns the canonical lowercase text, whatever spelling the client sent.

Query parameters

ctx.Query() returns a read-only, sorted view of the URL query with the same typed accessors and the same failure defaults as Params():

Go
// GET /search?q=iris&page=2&active=true&tags=go&tags=web
q := ctx.Query().GetString("q")        // "iris"
page := ctx.Query().GetInt("page")     // 2, -1 if missing or invalid
active := ctx.Query().GetBool("active") // true, false if missing
price := ctx.Query().GetFloat64("max") // -1 if missing

exists := ctx.Query().Exists("q")      // true
val, ok := ctx.Query().Lookup("q")     // "iris", true

A few getters parse the text into a richer type and return its zero value on a miss. GetDuration reads ?ttl=1h30m as 90 minutes, GetTime reads an RFC 3339 timestamp such as ?since=2026-10-10T09:00:00Z, and GetIP turns ?addr=10.0.0.7 into a netip.Addr whose IsValid() is false when the value is not an address:

Go
ttl := ctx.Query().GetDuration("ttl")    // time.Duration, 0 if missing or invalid
since := ctx.Query().GetTime("since")    // time.Time, zero if missing or invalid
addr := ctx.Query().GetIP("addr")        // netip.Addr
ref := ctx.Query().GetUUID("ref")        // uuid.UUID, uuid.Nil() if missing or invalid

Repeated keys are available in two forms:

Go
tags := ctx.Query().GetSlice("tags")        // ["go", "web"]: the original
                                            // []string from the query map.
cols := ctx.Query().GetStringSlice("columns") // splits the joined value by
                                              // the configured separator.

GetStringSlice splits on ContextOptions.URLParamSeparator (default ","), so ?columns=a,b,c and ?columns=a&columns=b&columns=c both produce three elements; GetSlice returns the raw multi-value list as the client sent it. There is no URLParam method family in v14. Use the Query() accessors or ReadQuery.

Struct binding uses the url tag, and ReadURL combines path parameters with the query in one call:

Go
type SearchFilter struct {
    UserID   int      `param:"id"`   // from the path.
    Keyword  string   `url:"q"`      // from the query.
    Tags     []string `url:"tags"`
    Page     int      `url:"page"`
}

app.Get("/users/{id:int}/search", func(ctx iris.Context) {
    var filter SearchFilter
    if err := ctx.ReadURL(&filter); err != nil {
        errors.InvalidArgument.Err(ctx, err)
        return
    }
    ctx.JSON(filter)
})

ctx.ReadQuery(&dst) does the same for the query alone.

Strict query readers

The accessors above are lenient by design: GetInt("page") answers -1 for a missing key and for ?page=banana, and a filter built on them silently returns the wrong rows instead of telling the client it sent nonsense. Three readers make that a 400 instead:

Go
// GET /reports?from=2026-01-01&limit=50&detailed=yes
from, ok := ctx.QueryDate("from", iris.QueryRequired())
if !ok {
    return // 400 already written: missing, or not a date.
}

limit, ok := ctx.QueryInt("limit", iris.QueryDefault("25"))
if !ok {
    return
}

detailed, ok := ctx.QueryBool("detailed")
if !ok {
    return
}

The second result is the ok of the Bind* family: false means the response was already written and the handler returns. A present but unparsable value is always a 400 naming the key and the shape expected, never a silent zero. An absent value is the zero value unless an option says otherwise.

OptionEffect
iris.QueryRequired()An absent or blank value is a 400 naming the key
iris.QueryDefault(raw)The raw string to parse when the key is absent, stated in the client’s own wire form ("2020-01-02", "25", "true"). It wins over QueryRequired
iris.QueryZeroValues(v...)The placeholders older clients send for “unset” ("0", "zero", "0000-00-00", "null"), each read exactly like an absent value

QueryDate parses types.SimpleDate (2006-01-02, or 2006-1-2) and QueryBool accepts what strconv.ParseBool accepts plus yes, no, on and off, case-insensitively. Nothing is special-cased below the options: ?from=null is a 400 unless QueryZeroValues("null") declared it, because what a raw value means is the endpoint’s decision and not the parser’s. An unparsable QueryDefault is a programmer error and panics on the first request rather than rendering as the client’s fault.

An endpoint that answers to more than one spelling of the same parameter states them once, separated by a space, and each is tried in the order given until one carries a value:

Go
date, ok := ctx.QueryDate("date day from") // ?day=2026-01-02 answers here
if !ok {
    return
}

A URL query key cannot contain a space, so a key without one is a single key and reads exactly as it always did. QueryInt and QueryBool take the same. When no key carried a value the 400 names all of them; when one carried an unparsable value it names only that one, since the client did not send the others.

Binding JSON Requests

For JSON APIs the recommended entry point is the generic context method ctx.BindJSON[T](). It decodes the body, runs the payload’s own Validate() error method when the type implements it (the iris.Validatable interface), and on any failure writes a structured 4xx response and returns false. The handler simply returns. You never call Validate by hand.

The rest/errors/validation package provides small field builders for those Validate methods:

Go
package main

import (
    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/rest/errors/validation"
)

type CreateUserInput struct {
    Name  string `json:"name"`
    Email string `json:"email"`
    Age   int    `json:"age"`
}

// Implementing Validate makes ctx.BindJSON run it after decoding.
func (in CreateUserInput) Validate() error {
    return validation.Join(
        validation.String("name", in.Name).NotEmpty().Length(2, 100),
        validation.String("email", in.Email).Email(),
        validation.Number("age", in.Age).Positive(),
    )
}

func createUser(ctx iris.Context) {
    input, ok := ctx.BindJSON[CreateUserInput]()
    if !ok {
        return // 400 already written: malformed JSON or failed Validate.
    }

    // input is decoded AND valid here.
    ctx.Created(input.Name, nil) // see Chapter 6 for responses.
}

func main() {
    app := iris.New()
    app.Post("/users", createUser)
    app.Listen(":8080")
}

A failed Validate renders each field error individually:

JSON
{
    "http_error_code": {"canonical_name": "INVALID_ARGUMENT", "status": 400},
    "message": "validation failure",
    "detail": "fields were invalid",
    "validation": [
        {"field": "name", "value": "",
         "reason": "must not be empty, must be between 2 and 100 characters"},
        {"field": "age", "value": 0, "reason": "must be positive"}
    ]
}

The builders cover strings (NotEmpty, Length, Email, Fullname), numbers (Positive, Negative, NonZero, InRange) and slices, and compose with plain functions through Func for custom rules. validation.Join drops entries that passed, and returns nil when everything did.

Optional bodies and variants

ctx.BindJSONOptional[T]() behaves like BindJSON except that an empty request body is a success: it returns the zero value of T with true and skips validation. Use it when the body is a nice-to-have, such as an optional filter object.

Two more variants accept a body that may be either a single object or an array of objects: ctx.BindJSONOneOrMany[T]() and ctx.BindJSONList[T]() (which normalizes a single object into a one-element slice). Every element is validated.

Partial updates and the column list

A PATCH sends the body and the list of fields it means to change. The list travels in the columns query key, and reading it apart from the body invites the bug the whole family exists to prevent: a typo matches no column, the update changes nothing, and the client is told it succeeded. ctx.BindUpdate[T]() reads both and validates them against each other:

Go
func updateUser(ctx iris.Context) {
    id, ok := ctx.BindParam[int64]("id")
    if !ok {
        return
    }

    input, columns, ok := ctx.BindUpdate[UpdateUserInput]()
    if !ok {
        return // 400 already written: the body, or an unknown column.
    }

    updated, err := svc.UpdateUser(ctx, id, input, columns...)
    ctx.NoContent(updated, err) // 204, or 304 when nothing changed.
}

The body half is BindJSON exactly: the same decoder, the same RequestValidator hook, the same Validate call. The columns arrive either as a repeated key (?columns=name&columns=email) or as one comma-separated value (?columns=name,email); both spellings are accepted, values are trimmed, empties dropped and duplicates removed, keeping the client’s order. No columns at all means a full update and yields a nil slice.

Every column is checked against the JSON member names of T, the json tag name or the Go field name where there is no tag, and an unknown one answers 400 naming the column and the names it could have been. The comparison is exact where the body decoder is case-insensitive, because a column name reaches an UPDATE statement: the endpoint must receive the spelling it declared rather than a normalized guess. The names that come back are still the client’s, so a store that builds SQL from them maps them to its own columns and never interpolates them.

The Context Read Family

When the payload is not JSON, or when you want to handle the error yourself instead of letting the helpers respond, the context offers a Read* method per format. Each decodes into a pointer, using a struct tag that matches the source:

MethodSourceStruct tag
ctx.ReadJSON(&v, opts...)JSON bodyjson
ctx.ReadXML(&v)XML bodyxml
ctx.ReadYAML(&v)YAML bodyyaml
ctx.ReadMsgPack(&v)MessagePack body—
ctx.ReadProtobuf(msg)Protobuf body (proto.Message)—
ctx.ReadForm(&v)URL-encoded or multipart formform
ctx.ReadQuery(&v)URL queryurl
ctx.ReadParams(&v)path parametersparam
ctx.ReadHeader(&v)request headersheader
ctx.ReadURL(&v)path parameters + queryparam, url
ctx.ReadBody(&v)dispatched by content typedepends

XML and YAML are, like JSON, human-readable text formats with different syntax. MessagePack and Protobuf are binary encodings: they trade readability for smaller, faster payloads and are common in service-to-service traffic rather than browser-facing APIs.

ctx.ReadJSON accepts optional iris.JSONReader options:

Go
var data map[string]any
err := ctx.ReadJSON(&data, iris.JSONReader{
    DisallowUnknownFields: true, // reject keys with no struct field.
})

Decoding runs on the standard library’s encoding/json/v2 (Go 1.27). Two of its stricter rules apply to every reader: a body with duplicate object keys or invalid UTF-8 is rejected (the bind helpers render that as a 400). One rule is deliberately relaxed: member names still match struct fields case-insensitively, as deployed clients expect from the old encoding/json; set JSONReader.CaseSensitive to restore v2’s strict matching for a call. The UnmarshalOptions field takes raw encoding/json/v2 options, appended after the defaults so yours win: jsontext.AllowDuplicateNames(true), for example, accepts duplicate members again.

For large arrays, ctx.ReadJSONStream decodes one object at a time instead of buffering the whole body:

Go
var users []User
err := ctx.ReadJSONStream(func(decode iris.DecodeFunc) error {
    var u User
    if err := decode(&u); err != nil {
        return err
    }
    users = append(users, u)
    return nil
})

ReadBody: content-type dispatch

ctx.ReadBody(&v) picks the decoder for you. For GET requests it reads from the URL query (falling back to the form). For other methods it switches on the request’s media type: XML, YAML, url-encoded or multipart forms, JSON, Protobuf and MessagePack are dispatched to the matching Read* method; an unrecognized type falls back to the query (when one exists) and finally to JSON.

How ctx.ReadBody chooses a decoder ctx.ReadBody short-circuits for raw string and byte targets, reads the query or the form for a GET, and otherwise switches on the media type, falling back to the query and then to JSON. ctx.ReadBody(ptr) ptr is *string or *[]byte? yes the raw body is copied in, with no decoding no the method is GET? yes ctx.ReadQuery(ptr) when the query is not empty, otherwise ctx.ReadForm(ptr) no switch on ctx.MediaType() text/xml, application/xml ctx.ReadXML(ptr) application/x-yaml, text/yaml ctx.ReadYAML(ptr) x-www-form-urlencoded, multipart/form-data ctx.ReadForm(ptr) multipart/related an error: use ctx.ReadMultipartRelated() application/json ctx.ReadJSON(ptr) application/x-protobuf ctx.ReadProtobuf(msg) application/msgpack, application/x-msgpack ctx.ReadMsgPack(ptr) anything else ctx.ReadQuery(ptr) when the query is not empty, otherwise ctx.ReadJSON(ptr) Empty input is not an error by default: ReadForm, ReadQuery and ReadBody leave the struct at its zero value. Set ContextOptions.FireEmptyFormError to get iris.ErrEmptyForm instead. ReadProtobuf returns ErrContentNotSupported when the target is not a proto.Message.
ctx.ReadBody short-circuits for raw string and byte targets, reads the query or the form for a GET, and otherwise switches on the media type, falling back to the query and then to JSON.
Go
var raw []byte
if err := ctx.ReadBody(&raw); err != nil { // raw body, no decoding.
    errors.InvalidArgument.Err(ctx, err)
    return
}

The lowest level is ctx.Body(), which returns the body as []byte. Like any HTTP body, it can be consumed once: a later ReadJSON on the same request finds it empty.

Empty-input behavior: ReadForm, ReadQuery and ReadBody return a nil error on empty input by default, leaving the struct at its zero value. Set ContextOptions.FireEmptyFormError to make them return iris.ErrEmptyForm instead. ReadForm errors on unknown client fields; ignore that class with err != nil && !iris.IsErrPath(err).

Pagination Input

Returning an entire table from one endpoint does not scale: the response grows without bound and most clients display a screenful at a time. Pagination splits the list into fixed-size pages the client requests one by one; in database terms, the page number and size translate into how many rows the query fetches and how many it skips first.

List endpoints usually accept ?page=&size=&details= plus an optional JSON filter body. ctx.BindPagination reads both at once:

Go
type BookFilter struct {
    Author string `json:"author"`
}

func listBooks(ctx iris.Context) {
    opts, filter, ok := ctx.BindPagination[BookFilter]()
    if !ok {
        return // error response already sent.
    }

    // opts.GetLimit(), opts.GetOffset() feed the database query.
    books, total, err := svc.Books(ctx, opts, filter)
    ctx.OK(iris.NewPage(opts, &filter, books, total), err)
}

iris.PageOptions carries Page, Size and Details; GetLimit returns Size bounded by pagination.MaxPageSize (falling back to pagination.DefaultPageSize, 100) and GetOffset derives the offset from the page number. Set the two bounds once at startup, either directly or through iris.Patches().Context().SetMaxPageSize(n) and SetDefaultPageSize(n). An empty body yields the zero filter. The filter is optional by design.

A filter body on a read raises the question of which method carries it. GET has no defined body semantics, and POST says the request changes something, which it does not. HTTP QUERY (RFC 10008) is the method made for this: safe and idempotent like GET, with a body like POST, and its response is cacheable. Iris registers it with app.Query, so one path can answer both shapes:

Go
app.Get("/books", listBooks)   // ?page=&size= only.
app.Query("/books", listBooks) // ?page=&size= plus a filter body.

Send the QUERY filter as JSON. On Go 1.27, net/http parses a url-encoded body only for POST, PUT and PATCH, so a form-encoded QUERY body never reaches ctx.Form*.

The page types themselves (PageOptions, Page[T], NewPage, ListFilter, CountResponse[N]) live in core/pkg/pagination, a package that imports only the standard library. iris and context alias them, so iris.Page[T] and pagination.Page[T] are the same type, and the client package decodes a list into that type too.

ctx.Paginated collapses the whole flow (read options and filter, run a collector, respond with a iris.Page[T]) into one call:

Go
import (
    "context"

    "github.com/kataras/iris/v14"
)

func listBooks(ctx iris.Context) {
    ctx.Paginated(func(reqCtx context.Context,
        opts iris.PageOptions, filter BookFilter) ([]Book, int64, error) {
        return svc.Books(reqCtx, opts, filter)
    })
}

The response shape (current_page, page_size, total_pages, total_items, has_next_page, filter, items) is covered in Chapter 6.

The shared filter, and who chooses the sort

Most list filters carry the same four or five members before they carry anything of their own. iris.ListFilter is that half, ready to embed:

Go
type BookFilter struct {
    iris.ListFilter

    Author string `json:"author,omitzero" url:"author"`
}

It fields Term, Names, Substring, Sort and SortDescending, and carries both tag sets on purpose: the json names for a QUERY or POST list body and the url names for a GET list route read through ReadQuery. f.SortColumn(allowed...) returns the requested column or an error wrapping iris.ErrSortNotAllowed, and f.SortOrder() returns "ASC" or "DESC".

A sort column reaches an ORDER BY clause, so which columns a list offers belongs to the endpoint and never to the request. Two options say so declaratively, and both answer 400 before the collector runs:

Go
func listBooks(ctx iris.Context) {
    ctx.Paginated(func(reqCtx context.Context,
        opts iris.PageOptions, filter BookFilter) ([]Book, int64, error) {
        return svc.Books(reqCtx, opts, filter)
    }, iris.AllowSort("title", "published_at"))
}

iris.AllowSort(columns...) limits the column a client may name, and the rejection names both the column and the allowed ones. iris.DenySort(message) refuses any sort at all, for a list whose order the query itself fixes. Both work on ctx.BindPagination too, and both read the decoded filter through its SortRequest method (the pagination.SortRequester interface), which ListFilter provides, so a filter without one is simply unaffected.

Sorting is often a permission rather than a shape. A list a client may read but not order should answer 403, not 400, and any error code carries the rejection:

Go
list, filter, ok := ctx.BindPagination[BookFilter](
    errors.PermissionDenied.DenySort("sorting is not available on this plan"))
if !ok {
    return
}

errors.ErrorCodeName.DenySort, from rest/errors, is iris.DenySort answered with that code and is otherwise identical.

A collector may take its filter by pointer, which is the shape a repository method already has, so it can be passed as a bare method value:

Go
ctx.Paginated(svc.ListBooksPaginated) // (context.Context, iris.PageOptions, *BookFilter) ([]Book, int64, error)

Such a collector never receives nil. A request with no body decodes to the filter’s zero value, so the repository reads an empty filter and the sort policy still has a real SortRequest to read.

Forms and File Uploads

Form values

HTML form submissions (application/x-www-form-urlencoded or multipart/form-data) bind through the form tag:

Go
type ContactForm struct {
    Name    string `form:"name"`
    Email   string `form:"email"`
    Message string `form:"message"`
}

func contact(ctx iris.Context) {
    var form ContactForm
    if err := ctx.ReadForm(&form); err != nil && !iris.IsErrPath(err) {
        errors.InvalidArgument.Err(ctx, err)
        return
    }
    // ...
}

Individual values are available through ctx.Form() (query + body values) and ctx.PostForm() (body values only), which return the same read-only entries view as ctx.Query(): GetString, GetInt, GetSlice and friends all apply.

Multipart form parsing buffers up to ContextOptions.PostMaxMemory in memory (default 32 MB, spillover goes to temporary files). Raise or lower it through iris.Options{Context: iris.ContextOptions{...}}.

File uploads

An uploaded file’s name is client input like any other. A malicious client can submit a name such as ../../etc/crontab, hoping the server joins it to the upload directory and writes outside it, an attack known as path traversal. Sanitizing the name before it touches the filesystem is therefore not optional, and the helper below does it for you.

ctx.UploadFormFiles is the batteries-included path: it saves every uploaded file into a destination directory, sanitizes file names against an allowlist pattern and blocks path-traversal attempts (unsafe names are silently skipped). The optional callback filters or renames files before saving:

Go
import (
    "mime/multipart"

    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/rest/errors"
)

func upload(ctx iris.Context) {
    files, total, err := ctx.UploadFormFiles("./uploads",
        func(ctx iris.Context, file *multipart.FileHeader) bool {
            return file.Size <= 8*iris.MB // reject anything larger.
        })
    if err != nil {
        errors.InvalidArgument.Err(ctx, err)
        return
    }

    ctx.JSON(iris.Map{"uploaded": len(files), "bytes": total})
}

For manual control, ctx.FormFile(key) returns the first uploaded file for a form key and ctx.FormFiles(key, ...) all of them; pair either with ctx.SaveFormFile(header, dest) to write to disk. When building the destination path yourself, sanitize the client-supplied name first. context.SafeFilename implements the same checks UploadFormFiles uses: it strips every directory component, holds the result to an allowlist of characters, rejects the reserved Windows device names (NUL, COM1, CON.txt and the rest, which open a device rather than a file), and resolves the destination directory before checking that the final path is inside it. The upload endpoints are a natural place for the body limits described below.

Cookies

A cookie is a small named value the server asks the browser to store; the browser then attaches it to every later request to the same site. That echo is what gives stateless HTTP a memory: it is how a login survives from one request to the next.

Two readers cover the request side:

Go
// Just the value; "" when the cookie is missing.
session := ctx.CookieValue("session_id")

// The full *http.Cookie; http.ErrNoCookie when missing.
cookie, err := ctx.Cookie("session_id")
if err == nil {
    _ = cookie.Value
}

CookieValue URL-unescapes the value for you. Setting, upserting and removing cookies happens on the response and is covered in Chapter 6.

Body Size Limits

Unbounded request bodies are a denial-of-service vector: a client can stream gigabytes into ReadJSON or hide them behind a compressed body. Iris offers an application-wide cap and a per-route middleware.

Application-wide: ContextOptions.BodyMaxMemory limits the total bytes read by Body, ReadJSON, ReadJSONStream and ReadMultipartRelated. That cap therefore also covers everything built on them, including ReadXML, ReadYAML, ReadBody and ctx.BindJSON:

Go
app := iris.New(iris.Options{
    Context: iris.ContextOptions{
        BodyMaxMemory: 4 * iris.MB,
    },
})

The default 0 means no limit. Reading past the cap fails with context.ErrRequestEntityTooLarge (package github.com/kataras/iris/v14/context), which the rest/errors package renders as a 413 TOO_LARGE response.

Per route or group: the bodylimit middleware applies the same enforcement wherever it is registered, typically on upload routes:

Go
import "github.com/kataras/iris/v14/middleware/bodylimit"

media := app.Router("/media", bodylimit.New(20*iris.MB))
media.Post("/upload", upload)

It rejects requests up front when the declared Content-Length already exceeds the limit, and wraps the body reader to catch chunked or compressed bodies that only reveal their size while being read.

PostMaxMemory is a different knob: it bounds how much of a multipart form is buffered in memory during parsing, not how large the body may be. Use both where appropriate.

The RequestValidator Hook

The per-type Validate() error method with ctx.BindJSON is the recommended validation path. For teams standardizing on a tag-based validation library instead, the context exposes one application-level hook: ContextOptions.RequestValidator, any value implementing

Go
type Validator interface {
    Struct(any) error
}

It defaults to nil, so validate:"..." struct tags do nothing on their own. When set, every decoding Read* method (ReadJSON, ReadXML, ReadYAML, ReadMsgPack, ReadForm, ReadQuery, ReadParams, ReadURL, ReadHeader, ReadBody) calls it after a successful decode and returns its error. Wiring a third-party library is a three-line adapter:

Go
import (
    "github.com/go-playground/validator/v10" // third-party.

    "github.com/kataras/iris/v14"
)

type structValidator struct{ v *validator.Validate }

func (s structValidator) Struct(ptr any) error { return s.v.Struct(ptr) }

func main() {
    app := iris.New(iris.Options{
        Context: iris.ContextOptions{
            RequestValidator: structValidator{validator.New()},
        },
    })
    // ...
}

The two mechanisms compose: ctx.BindJSON surfaces RequestValidator failures through the same structured 400 responses it uses for a failed Validate() method, so handlers keep the single if !ok { return } guard either way.

Summary

  • ctx.Method(), ctx.Path(), ctx.Host() and friends expose request metadata; ctx.RemoteAddr() trusts proxy headers only when RemoteAddrHeaders is configured.
  • ctx.Header(name) reads a request header (repeated values joined by ", "); ctx.ContentType() / ctx.MediaType() describe the request body’s declared type.
  • ctx.Params() and ctx.Query() share typed accessors with fixed failure defaults (GetString → "", GetInt → -1); Lookup and Exists distinguish missing from zero; GetSlice returns raw multi-values.
  • ctx.BindJSON[T]() is the recommended body reader: it decodes, runs the type’s Validate() error automatically, and writes structured 4xx responses. ctx.BindJSONOptional[T]() accepts an empty body.
  • The ctx.Read* family covers JSON, XML, YAML, MessagePack, Protobuf, forms, query, params and headers; ctx.ReadBody dispatches by content type and fills *string/*[]byte with the raw body.
  • ctx.BindPagination and ctx.Paginated turn ?page=&size= plus an optional filter body into a complete list endpoint.
  • ctx.UploadFormFiles saves multipart uploads with sanitized names; PostMaxMemory bounds form buffering, BodyMaxMemory and the bodylimit middleware cap body size (413 on overflow).
  • ContextOptions.RequestValidator is the app-wide hook for tag-based validation libraries; without it, validate tags are inert.

Further Reading

Twenty-five chapters, about 85,000 words. Try BindJSON, graceful shutdown or error map.