REST API Design Patterns

REST is simple in theory. In practice, you'll hit a dozen design decisions before you've defined your third endpoint. Should this be a nested resource or a query parameter? When do you use PUT vs PATCH? How do you handle bulk operations without breaking REST conventions? This guide covers the

TRY NANO BANANA FOR FREE

REST API Design Patterns

TRY NANO BANANA FOR FREE
Contents

REST is simple in theory. In practice, you'll hit a dozen design decisions before you've defined your third endpoint. Should this be a nested resource or a query parameter? When do you use PUT vs PATCH? How do you handle bulk operations without breaking REST conventions?

This guide covers the patterns that come up repeatedly in real API design, using the PetStore API as a concrete reference throughout.


Resource Naming: The Foundation of a Good API

Resource names are the nouns of your API. Get them right and everything else flows naturally. Get them wrong and you'll be explaining your URL structure in every support ticket.

Use plural nouns for collections:

GET /pets          # list of pets
GET /pets/10       # specific pet
GET /orders        # list of orders
GET /orders/7      # specific order

Not /getPet or /pet/list — those are RPC-style, not REST. Resources are things, not actions.

Use lowercase with hyphens for multi-word resources:

GET /pet-categories     # correct
GET /petCategories      # avoid (camelCase in URLs is inconsistent across clients)
GET /pet_categories     # avoid (underscores can be hidden by link underlines)

Keep URLs as short as meaningful:

GET /pets/10/photos     # good
GET /pets/10/all-photos-list   # unnecessary verbosity

The PetStore API follows these conventions cleanly. Its core resources are /pet, /store, and /user — simple, predictable, and easy to remember.


Nested Resources: When to Go Deep

Nested resources express ownership or containment. A pet has photos. An order has items. The question is how deep to nest.

One level of nesting is usually fine:

GET /pets/10/photos          # photos belonging to pet 10
POST /pets/10/photos         # add a photo to pet 10
DELETE /pets/10/photos/5     # delete photo 5 from pet 10

Two levels gets awkward:

GET /stores/1/orders/7/items/3    # hard to read, hard to cache

When you find yourself going three levels deep, it's usually a sign that the nested resource should be a top-level resource with a filter parameter instead:

# Instead of:
GET /stores/1/orders/7/items

# Consider:
GET /order-items?orderId=7

The PetStore API keeps nesting shallow. /store/order/{orderId} is as deep as it goes — one level of nesting under the store context.

Avoid nesting when the child resource has its own identity:

If you can access a resource directly by ID without knowing its parent, it probably shouldn't be nested. Orders in the PetStore are a good example — you can fetch /store/order/7 directly without knowing which store it belongs to.


HTTP Methods: Using the Right Verb

Each HTTP method has a specific semantic meaning. Using them correctly makes your API predictable.

Method Use case Idempotent? Safe?
GET Retrieve a resource Yes Yes
POST Create a new resource No No
PUT Replace a resource entirely Yes No
PATCH Partially update a resource No* No
DELETE Remove a resource Yes No

*PATCH idempotency depends on implementation.

POST for creation:

POST /pet
Content-Type: application/json

{
  "name": "Max",
  "photoUrls": ["https://example.com/max.jpg"],
  "status": "available"
}

Response: 201 Created with the new resource in the body and a Location header pointing to it.

PUT for full replacement:

PUT /pet/10
Content-Type: application/json

{
  "id": 10,
  "name": "Max",
  "photoUrls": ["https://example.com/max-updated.jpg"],
  "status": "pending"
}

PUT replaces the entire resource. If you omit a field, it gets cleared. This is why PUT requires the full representation.


PATCH for Partial Updates

PATCH is the right tool when you want to update one or two fields without sending the entire resource. It's more efficient and less error-prone than PUT for partial updates.

Simple PATCH (merge patch, RFC 7396):

PATCH /pet/10
Content-Type: application/merge-patch+json

{
  "status": "sold"
}

Only the status field changes. Everything else stays the same.

JSON Patch (RFC 6902) for precise operations:

PATCH /pet/10
Content-Type: application/json-patch+json

[
  { "op": "replace", "path": "/status", "value": "sold" },
  { "op": "add", "path": "/tags/-", "value": { "id": 5, "name": "adopted" } }
]

JSON Patch is more verbose but gives you fine-grained control — you can add, remove, replace, move, copy, or test individual fields.

For most APIs, merge patch is simpler and sufficient. Use JSON Patch when you need atomic multi-field operations or array manipulation.

Python example for PATCH:

import requests

# Merge patch — update only the status
response = requests.patch(
    "https://petstore3.swagger.io/api/v3/pet/10",
    json={"status": "sold"},
    headers={
        "Content-Type": "application/merge-patch+json",
        "api_key": "your-api-key"
    }
)

if response.status_code == 200:
    updated_pet = response.json()
    print(f"Status updated to: {updated_pet['status']}")

Bulk Operations

Single-resource endpoints are clean, but sometimes you need to create, update, or delete many resources at once. Doing 500 individual POST requests is neither efficient nor practical.

Bulk create:

POST /pets/bulk
Content-Type: application/json

{
  "pets": [
    { "name": "Buddy", "photoUrls": ["..."], "status": "available" },
    { "name": "Luna", "photoUrls": ["..."], "status": "available" },
    { "name": "Charlie", "photoUrls": ["..."], "status": "pending" }
  ]
}

Response should include results for each item, including any that failed:

{
  "created": 2,
  "failed": 1,
  "results": [
    { "index": 0, "id": 101, "status": "created" },
    { "index": 1, "id": 102, "status": "created" },
    { "index": 2, "error": "Invalid status value", "status": "failed" }
  ]
}

Bulk update with PATCH:

PATCH /pets/bulk
Content-Type: application/json

{
  "ids": [10, 11, 12],
  "patch": { "status": "sold" }
}

Bulk delete:

DELETE /pets/bulk
Content-Type: application/json

{
  "ids": [10, 11, 12]
}

Some teams prefer using query parameters for bulk delete: DELETE /pets?ids=10,11,12. Both approaches work — pick one and be consistent.

JavaScript bulk create example:

async function bulkCreatePets(pets) {
  const response = await fetch('https://petstore3.swagger.io/api/v3/pets/bulk', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'api_key': 'your-api-key'
    },
    body: JSON.stringify({ pets })
  });

  const result = await response.json();

  if (result.failed > 0) {
    const failures = result.results.filter(r => r.status === 'failed');
    console.warn(`${result.failed} pets failed to create:`, failures);
  }

  return result;
}

Collection Resources vs Singleton Resources

Most resources are collections — lists of things you can paginate through. But some resources are singletons — there's exactly one of them in a given context.

Collection resource:

GET /pets           # returns a list
POST /pets          # creates a new item in the list
GET /pets/10        # returns a specific item

Singleton resource:

GET /store/inventory        # the store has exactly one inventory
PUT /store/inventory        # replace the inventory
PATCH /store/inventory      # update part of the inventory

The PetStore API has a good example of this: GET /store/inventory returns the store's inventory as a singleton — there's no /store/inventory/1 because there's only one inventory.

User profiles are another common singleton pattern:

GET /users/me           # the authenticated user's profile
PATCH /users/me         # update my profile
DELETE /users/me        # delete my account

Using /me instead of requiring the user to know their own ID is a nice ergonomic touch.


Filtering, Sorting, and Searching Collections

Collections need filtering. The PetStore API uses query parameters for this:

GET /pet/findByStatus?status=available
GET /pet/findByTags?tags=dog,puppy

A more RESTful approach uses the collection endpoint with filter parameters:

GET /pets?status=available
GET /pets?tags=dog,puppy
GET /pets?status=available&tags=dog&sort=name&order=asc
GET /pets?name=max&limit=10&offset=20

Consistent parameter naming matters:

# Pick one style and stick to it:
?sort=name&order=asc       # explicit sort field and direction
?sort=+name                # prefix notation (+ for asc, - for desc)
?orderBy=name_asc          # combined field

Full-text search gets its own parameter:

GET /pets?q=golden+retriever

HATEOAS: Hypermedia as the Engine of Application State

HATEOAS is the "H" in REST that most APIs skip. The idea is that responses include links to related actions, so clients don't need to hardcode URLs.

A HATEOAS-style PetStore response might look like:

{
  "id": 10,
  "name": "Max",
  "status": "available",
  "_links": {
    "self": { "href": "/pets/10" },
    "photos": { "href": "/pets/10/photos" },
    "order": { "href": "/store/order", "method": "POST" },
    "update": { "href": "/pets/10", "method": "PUT" },
    "delete": { "href": "/pets/10", "method": "DELETE" }
  }
}

The client can discover what it can do with this resource from the response itself, rather than consulting separate documentation.

Is HATEOAS worth implementing?

Honestly, it depends. For public APIs with many different clients, HATEOAS reduces coupling between client and server — you can change URLs without breaking clients. For internal APIs where you control both sides, the overhead often isn't worth it.

If you do implement HATEOAS, HAL (Hypertext Application Language) and JSON:API are two established formats worth looking at rather than inventing your own.


Versioning Strategies

APIs change. How you version them affects how painful those changes are for your users.

URL versioning (most common):

https://petstore3.swagger.io/api/v3/pet

Simple, visible, easy to route. The downside is that URLs aren't technically "pure" REST — the version isn't a property of the resource.

Header versioning:

GET /pet/10
Accept: application/vnd.petstore.v3+json

Keeps URLs clean but makes versioning less discoverable.

Query parameter versioning:

GET /pet/10?version=3

Easy to test in a browser but mixes versioning with filtering concerns.

URL versioning wins in practice because it's the most obvious and easiest to work with in every HTTP client.


Idempotency Keys for Safe Retries

Network failures happen. When a client retries a POST request, you might create duplicate resources. Idempotency keys solve this.

POST /store/order
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{
  "petId": 10,
  "quantity": 1,
  "shipDate": "2024-03-15T10:00:00Z"
}

If the server receives the same idempotency key twice, it returns the result of the first request instead of creating a duplicate. The key should be a UUID generated by the client.


Putting It Together

Good REST API design isn't about following rules for their own sake — it's about making your API predictable. When developers can guess how your API works before reading the docs, you've done it right.

The patterns here — consistent resource naming, appropriate HTTP methods, shallow nesting, PATCH for partial updates, bulk endpoints where needed — are the building blocks of an API that's a pleasure to work with.

The PetStore API demonstrates most of these patterns in a compact, learnable form. It's worth spending time with it not just as a test target, but as a design reference.