Designing CSV, XML and Bulk Endpoints in REST APIs Cleanly
AI generated
{ }
GET
REST API · CSV · XML · Bulk · Content Negotiation
Designing CSV, XML and Bulk Endpoints
in REST APIs Cleanly

Alternative formats and mass operations present REST APIs with real design problems: when to use content negotiation, when a dedicated endpoint? How do you stream thousands of records with reasonable performance? How does a bulk operation communicate partial failures clearly enough for clients to react sensibly?

18 min read Content Negotiation · CSV Streaming · Bulk Error Model · OpenAPI Symfony · PHP 8.4 · REST

1. Why alternative formats in REST APIs are a design problem

REST APIs typically deliver JSON. That is entirely sufficient for browser frontends, mobile apps and microservice communication. But as soon as accounting systems, ERP integrations or data analysis teams need to be connected, other requirements immediately appear: CSV for direct Excel import, XML for DATEV or SAP interfaces, and bulk operations that process several hundred records in a single request. These requirements cannot simply be solved with an extra serializer. They raise questions about API design, error modeling, performance and documentation that never come up in a JSON-only context.

The most common mistake: alternative formats are treated as a technical detail that a developer "quickly bolts on" on a Friday afternoon. The result is endpoints like /export?format=csv that return no clear error codes, support no proper HTTP caching, ignore the Accept header and do not show up in OpenAPI at all. Integrators fall back on trial and error, bugs are only discovered in production, and the API is effectively undocumented for every non-JSON format. Yet all three problem areas, alternative formats, streaming and bulk operations, can be solved cleanly with clearly defined patterns.

2. Content negotiation: Accept header vs. dedicated endpoints

HTTP offers a standard mechanism with content negotiation: the client sends an Accept header, and the server responds with the best matching format while setting the response's Content-Type. That sounds elegant, but has practical limits. Browsers send Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8, which means that a browser call to a JSON API can suddenly get XML back. Content negotiation is also harder to configure with proxies and CDNs, since the Vary: Accept header has to be set correctly and interpreted by the cache.

The pragmatic alternative is dedicated endpoints or path segments for alternative formats: GET /api/orders/export.csv or GET /api/orders/feed.xml. This variant is browser friendly, cacheable without Vary complexity, and clearly identifiable in logs. For bulk operations, a dedicated endpoint such as POST /api/orders/bulk is recommended, because bulk semantics (partial errors, transaction boundaries, asynchronous processing) differ so fundamentally from normal CRUD operations that a shared endpoint causes more confusion than it resolves. The decision rule: content negotiation for representations that are identical in content, dedicated endpoints as soon as format or semantics differ substantially.

# OpenAPI: content negotiation for JSON and CSV at the same endpoint
/api/orders:
  get:
    summary: Retrieve orders
    parameters:
      - name: Accept
        in: header
        schema:
          type: string
          enum:
            - application/json
            - text/csv
          default: application/json
    responses:
      '200':
        description: Orders
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrderCollection'
          text/csv:
            schema:
              type: string
              format: binary
            headers:
              Content-Disposition:
                schema:
                  type: string
                  example: 'attachment; filename="orders-2026-05-10.csv"'
      '406':
        description: Requested format is not supported
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProblemDetail'

3. CSV export: streaming instead of a memory bomb

A CSV export via a REST API always starts with the same temptation: load all records from the database, convert them into a string array, return them as a text/csv response. With 500 rows, that works fine. With 50,000 rows, the PHP process sits stuck in memory with 512 MB of RAM while the client waits. The pattern behind this is structurally wrong: the response is built up completely in memory before the first byte reaches the client.

The correct solution is HTTP streaming with a PHP generator function or a Symfony StreamedResponse. The server opens the HTTP connection, sends the CSV header, and then writes row by row into the output buffer while simultaneously reading from the database in batches. The client receives data continuously, and the server only needs as much memory as a single batch. Critically, Transfer-Encoding: chunked is activated by PHP via flush(), and Symfony proxies or nginx buffers must be disabled (X-Accel-Buffering: no) so the client receives chunks immediately instead of waiting for the entire response.

<?php
// Symfony StreamedResponse for a large CSV export without memory overhead
use Symfony\Component\HttpFoundation\StreamedResponse;
use Doctrine\ORM\EntityManagerInterface;

final class OrderExportController
{
    public function __construct(
        private readonly EntityManagerInterface $em,
    ) {}

    public function __invoke(): StreamedResponse
    {
        return new StreamedResponse(function (): void {
            $handle = fopen('php://output', 'w');

            // UTF-8 BOM for Excel compatibility
            fwrite($handle, "\xEF\xBB\xBF");

            // Header row
            fputcsv($handle, ['ID', 'Datum', 'Kunde', 'Betrag', 'Status'], ';');

            $batchSize = 500;
            $offset    = 0;

            do {
                $rows = $this->em->createQuery(
                    'SELECT o.id, o.createdAt, o.customerName, o.total, o.status
                     FROM App\Entity\Order o ORDER BY o.id ASC'
                )
                    ->setFirstResult($offset)
                    ->setMaxResults($batchSize)
                    ->getArrayResult();

                foreach ($rows as $row) {
                    fputcsv($handle, [
                        $row['id'],
                        $row['createdAt']->format('Y-m-d'),
                        $row['customerName'],
                        number_format($row['total'], 2, ',', '.'),
                        $row['status'],
                    ], ';');
                }

                flush(); // Send chunk to client immediately
                $this->em->clear(); // Free Doctrine identity map
                $offset += $batchSize;
            } while (count($rows) === $batchSize);

            fclose($handle);
        }, 200, [
            'Content-Type'        => 'text/csv; charset=UTF-8',
            'Content-Disposition' => 'attachment; filename="orders-' . date('Y-m-d') . '.csv"',
            'X-Accel-Buffering'   => 'no', // Disable nginx buffering
            'Cache-Control'       => 'no-store',
        ]);
    }
}

4. XML feeds: namespaces, schemas and validation

XML feeds for external systems, ERP, DATEV, Google Merchant Center or comparison portals, generally follow a predefined schema that the recipient expects. The real problem is not XML generation but consistency: a deviation in the namespace, a missing required field or a wrong date format causes the recipient to reject the entire feed, without providing a meaningful error message. Producing valid XML against a defined XSD schema is therefore not an optional quality step but a basic requirement for working integrations.

In PHP, the cleanest way to generate XML feeds is with XMLWriter for streaming, or with DOMDocument for smaller, structurally complex documents. SimpleXML is suitable for reading, but not for clean writing with namespaces. After generation, you validate against the XSD schema with DOMDocument::schemaValidate(). In production, this validation belongs in an automated test, not in the request handler itself, because validation costs time and fails on schema changes that you want to catch early in CI. For the API endpoint, the requirement is: deliver valid XML and set a Last-Modified header so clients can poll efficiently without downloading the entire feed on every request.

5. Bulk endpoints: semantics, idempotency and transaction boundaries

A bulk endpoint accepts multiple resources in a single request. That sounds simple, but the design questions are considerable: should the bulk operation be atomic, meaning either all entries are processed or none? Or should each entry be processed independently, with individual success or failure? Is the operation idempotent, meaning it can be safely retried if the response was lost? These questions determine the HTTP method, the status code and the error model.

For atomic bulk operations (all or nothing), POST /api/orders/bulk with a database transaction in the handler is suitable. On failure, 422 or 400 is returned, and nothing was persisted. For non-atomic bulk operations with individual results, the endpoint returns 207 Multi-Status, where each entry in the request has its own status code in the response. Idempotent bulk operations, such as setting the status for a set of orders, fit PATCH /api/orders/bulk with a unique idempotency key in the header. The server checks the key and, on a duplicate request, returns the cached response without executing the operation again.

// POST /api/orders/bulk - Non-atomic bulk creation, 207 Multi-Status response
{
  "results": [
    {
      "index": 0,
      "status": 201,
      "id": "ORD-10042",
      "href": "/api/orders/ORD-10042"
    },
    {
      "index": 1,
      "status": 422,
      "error": {
        "type": "https://mironsoft.de/errors/validation-failed",
        "title": "Validation error",
        "detail": "Field 'customerEmail' is missing or invalid.",
        "field": "customerEmail"
      }
    },
    {
      "index": 2,
      "status": 201,
      "id": "ORD-10043",
      "href": "/api/orders/ORD-10043"
    }
  ],
  "summary": {
    "total": 3,
    "succeeded": 2,
    "failed": 1
  }
}

6. Modeling partial errors in bulk operations correctly

HTTP status codes are designed for individual resource requests: 200, 201, 400, 404, 422. As soon as a bulk operation partly succeeds and partly fails, no single status code is enough anymore. The HTTP protocol defines 207 Multi-Status exactly for this case, a status code that originates from the WebDAV standard but applies to any kind of bulk operation in REST APIs. The response body contains its own status code per entry, so the client knows precisely which entries were processed successfully and which were not.

Besides 207 Multi-Status, there are two other common patterns for partial errors. The first: the endpoint accepts atomic semantics and returns 422 on any error, with a list of all validation errors in the body. The second: the bulk operation is processed asynchronously, the endpoint immediately returns 202 Accepted with a job ID, and the client polls GET /api/jobs/{id} for the status. Which pattern fits depends on processing duration and the client's requirements: synchronous 207 responses are suitable for small batches (up to roughly 100 entries), asynchronous jobs for large imports (thousands of entries), where a synchronous connection would be at risk of timing out.

7. Documenting CSV, XML and bulk in OpenAPI

The biggest documentation gap in REST APIs with alternative formats is OpenAPI. Many teams document JSON cleanly, but CSV exports are completely missing from the schema, bulk endpoints have no examples for 207 responses, and XML feeds do not even appear as an endpoint. Integrators only find the formats through trial and error or by asking, which in practice leads to long onboarding times and support overhead.

OpenAPI 3.1 supports multiple content types per response, so JSON and CSV can be documented at the same endpoint. For bulk endpoints with 207 Multi-Status, you define a schema for the array of individual results that models both successful and failed entries. For XML feeds, storing an example document in the example property is recommended. The most important documentation step for bulk operations: explicitly model the summary object in the response, so integrators understand that partial errors are not signaled with an HTTP error code but are found in the body.

# OpenAPI: bulk endpoint with 207 Multi-Status
/api/orders/bulk:
  post:
    summary: Create multiple orders in one request
    description: >
      Non-atomic bulk creation. Each order is processed independently.
      Partial errors are reported with HTTP 207 Multi-Status and individual
      status codes per entry.
    requestBody:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [orders]
            properties:
              orders:
                type: array
                minItems: 1
                maxItems: 500
                items:
                  $ref: '#/components/schemas/OrderCreate'
          example:
            orders:
              - customerEmail: "max@example.com"
                total: 149.99
              - customerEmail: ""
                total: 89.00
    responses:
      '207':
        description: Bulk result with individual status codes
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkResult'
      '400':
        description: Invalid request (e.g. array missing or too large)
      '429':
        description: Rate limit for bulk operations exceeded

8. Design decisions compared

The choice between different approaches for alternative formats and bulk operations is not a matter of style, it has direct consequences for cacheability, client complexity and error diagnosis.

Scenario Problematic approach Recommended pattern Benefit
CSV export ?format=csv query parameter Accept header or /export.csv HTTP standard, cacheable with Vary
Large export Load everything into memory StreamedResponse + batches Constant memory footprint
Bulk error 500 or 400 on partial failure 207 Multi-Status + index Precise error localization
Large bulk import Synchronous request (timeout) 202 Accepted + job polling No timeout risk
XML validation Manual review only XSD validation in CI Catch schema errors early

A frequently overlooked point about bulk endpoints: the maximum batch size must be communicated explicitly in the OpenAPI schema and in the X-Max-Bulk-Size response header. Clients that are not aware of a size limit will eventually send batches with 10,000 entries that block the server. The 429 response should include a Retry-After header and explicitly explain whether the limit applies per minute, per hour or per connection.

9. Summary

CSV, XML and bulk endpoints are not special cases that get glued onto a REST API afterward, they are fully fledged parts of API design with their own requirements for HTTP semantics, error modeling and documentation. Content negotiation via the Accept header is the HTTP standard for alternative formats at the same endpoint; dedicated paths like /export.csv are the pragmatic alternative for browser friendly, clearly loggable exports. Streaming with StreamedResponse and database batches is not optional for large exports, it is a basic requirement for stable operation.

Bulk endpoints need clear transaction boundaries: atomic with 422 on error, or non-atomic with 207 Multi-Status and individual results per entry. Asynchronous processing with 202 and job polling is the right choice for large imports. OpenAPI documentation must cover all formats, all status codes and the error structure, including non-JSON formats. Teams that plan alternative formats and bulk operations into their API design from the start avoid the typical rework effort and give their integrators an API that works without trial and error.

CSV, XML and Bulk in REST APIs, the Essentials at a Glance

Content Negotiation

Accept header for formats identical in content. Dedicated endpoints as soon as semantics or format differ substantially. Always set Vary: Accept.

CSV Streaming

StreamedResponse + database batches + flush(). X-Accel-Buffering: no for nginx. UTF-8 BOM for Excel. Never build the full result in memory.

Bulk Error Model

207 Multi-Status for partial errors. Index in the response for mapping. Summary object with total/succeeded/failed. Document the maximum batch size.

OpenAPI Documentation

All formats as content types. Model the 207 schema fully. Store an XML example. Document rate limits and batch sizes as parameters.

10. FAQ: CSV, XML and Bulk Endpoints in REST APIs

1When content negotiation, when dedicated endpoints?
Content negotiation for representations identical in content. Dedicated endpoints when semantics or format differ substantially or browser friendly URLs are needed.
2Why StreamedResponse for CSV?
Prevents all records from being held in memory at once. With batches and flush(), memory usage stays constant, regardless of export size.
3What is HTTP 207 Multi-Status?
Signals a bulk operation that was partly successful and partly failed. The response body contains its own status code per entry. Suitable for non-atomic bulk operations.
4Maximum size for bulk requests?
100 to 500 entries for synchronous processing. Larger batches asynchronously: 202 Accepted with a job ID, status retrievable via GET /api/jobs/{id}.
5Document CSV in OpenAPI?
Multiple content types in the responses object: application/json and text/csv. Document the Content-Disposition header. Define the Accept header as a parameter.
6Which XML tool in PHP?
XMLWriter for streaming feeds. DOMDocument for complex documents with XSD validation. SimpleXML only for reading, not for feeds with namespaces.
7Prevent timeouts on large bulk imports?
Asynchronous processing: return 202 Accepted with a job ID immediately. Processing as a queue job. Client polls GET /api/jobs/{id} until completion.
8Must a bulk operation be atomic?
No. Atomic: all or nothing, 422 on error. Non-atomic: each entry independent, 207 with individual status codes. Document the choice based on business requirements.
9X-Accel-Buffering: why does it matter?
Disables nginx buffering for the request. Without this header, nginx collects all chunks and only sends them at the end, so streaming brings no benefit.
10Test bulk endpoints with partial errors?
Test batch with mixed entries: valid and invalid in the same request. Check: HTTP 207, succeeded/failed in the summary, correct error indexes for failed entries.