📖 3 min read (~ 600 words).

Streaming bodies

For payloads that are not naturally a single Go value — large file downloads, log streams, raw binary uploads — runtime.ByteStreamConsumer and runtime.ByteStreamProducer give you io.Reader / io.Writer access without the runtime decoding into a typed model.

Server — streaming a download

Spec:

paths:
  /backups/{id}:
    get:
      operationId: GetBackup
      produces:
        - application/octet-stream
      responses:
        '200':
          description: backup blob
          schema:
            type: string
            format: binary

Wiring:

api := untyped.NewAPI(doc).WithJSONDefaults()

// ByteStreamProducer is registered by WithJSONDefaults under
// runtime.DefaultMime ("application/octet-stream"), but be explicit
// when more than one stream-producing MIME is in the picture:
api.RegisterProducer(runtime.DefaultMime, runtime.ByteStreamProducer())

api.RegisterOperation("get", "/backups/{id}", runtime.OperationHandlerFunc(
	func(_ any) (any, error) {
		f, err := os.Open("/var/backups/2026-05-10.tar")
		if err != nil {
			return nil, err
		}
		// The Producer copies whatever io.Reader you return into the
		// response writer. Returning *os.File is fine; close it from
		// a Responder if you need ownership semantics.
		return middleware.ResponderFunc(func(w http.ResponseWriter, p runtime.Producer) {
			defer f.Close()
			w.Header().Set("Content-Type", runtime.DefaultMime)
			_ = p.Produce(w, f)
		}), nil
	},
))

Full source: docs/examples/contenttypes/streamingbodies/main.go

Produce accepts an io.Reader (yes, despite the name): the default ByteStreamProducer copies bytes through. For typed bodies the runtime would marshal first; here you stay in raw-byte territory end to end.

Server — streaming an upload

Spec:

paths:
  /backups:
    post:
      operationId: PutBackup
      consumes:
        - application/octet-stream
      parameters:
        - in: body
          name: blob
          schema:
            type: string
            format: binary
      responses: {…}

Wiring:

api.RegisterConsumer(runtime.DefaultMime, runtime.ByteStreamConsumer(
	runtime.ClosesStream, // closes the io.ReadCloser when done
))

Full source: docs/examples/contenttypes/streamingbodies/main.go

ClosesStream is the option to use when the consumer should Close() the underlying reader after consumption. Default is not to close — useful when you want to inspect the same body twice or the caller manages the lifetime explicitly.

The bound parameter is an io.ReadCloser; stream straight to disk:

api.RegisterOperation("post", "/backups", runtime.OperationHandlerFunc(
	func(params any) (any, error) {
		body := params.(putBackupParams).Blob // io.ReadCloser
		defer body.Close()

		f, err := os.CreateTemp("", "upload-*")
		if err != nil {
			return nil, err
		}
		defer f.Close()

		if _, err := io.Copy(f, body); err != nil {
			return nil, err
		}
		return map[string]string{"status": "ok"}, nil
	},
))

Full source: docs/examples/contenttypes/streamingbodies/main.go

Client — sending and receiving streams

Build a client request whose body is an io.Reader (or runtime.NamedReadCloser if you also want a filename for the Content-Disposition):

file, _ := os.Open("./backup.tar")
defer file.Close()

op := &runtime.ClientOperation{
	ID:                 "PutBackup",
	Method:             "POST",
	PathPattern:        "/backups",
	ConsumesMediaTypes: []string{runtime.DefaultMime},
	Params:             putBackupRequest{body: file},
	Reader:             putBackupResponse{},
}
_, err := rt.SubmitContext(ctx, op)

Full source: docs/examples/contenttypes/streamingbodies/main.go

For multipart uploads with file parts and form fields, the shape differs — see client multipart (queued).

Choosing between ByteStream and a typed Consumer

Use ByteStreamConsumer / Producer when:

  • the payload is genuinely opaque bytes (downloads, uploads of binary blobs, logs)
  • the size could exceed RAM — buffered codecs would OOM
  • you want to forward the body to another service without re-encoding

Use a typed Consumer/Producer (JSON, XML, …, custom codec) when the payload is a structured value the operation handler needs to inspect.

The two are not mutually exclusive — a single API can route some operations to streams and others to typed payloads via operation-level consumes / produces.