HTTP POST, PUT and PATCH — Creating and Updating Resources

POST creates resources, PUT replaces them entirely, and PATCH updates specific fields. Each has distinct semantics that both the server and client must respect. POST is the most common write operation; PUT and PATCH are used for updates but with important differences in how complete the request must be. Getting these right — including the correct status codes and proper partial update handling — is the mark of a well-designed REST API.

POST, PUT and PATCH

[ApiController]
[Route("api/posts")]
public class PostsController(IPostService service) : ControllerBase
{
    // ── POST — create, return 201 with Location ────────────────────────────
    // POST /api/posts
    // Body: { "title": "New Post", "body": "Content...", "slug": "new-post" }
    [HttpPost]
    [ProducesResponseType(typeof(PostDto), 201)]
    [ProducesResponseType(typeof(ValidationProblemDetails), 400)]
    [ProducesResponseType(typeof(ProblemDetails), 409)]
    public async Task<ActionResult<PostDto>> Create(
        CreatePostRequest request, CancellationToken ct)
    {
        if (await service.SlugExistsAsync(request.Slug, ct))
            return Conflict(new { message = "Slug is already in use.", field = "slug" });

        var post = await service.CreateAsync(request, ct);
        return CreatedAtAction(nameof(GetById), new { id = post.Id }, post);
    }

    // ── PUT — full replace (all fields required) ──────────────────────────
    // PUT /api/posts/42
    // Body: ALL post fields (omitted fields are set to null/default)
    [HttpPut("{id:int}")]
    [ProducesResponseType(typeof(PostDto), 200)]
    [ProducesResponseType(404)]
    public async Task<ActionResult<PostDto>> Replace(
        int id, ReplacePostRequest request, CancellationToken ct)
    {
        var post = await service.ReplaceAsync(id, request, ct);
        return post is null ? NotFound() : Ok(post);
    }

    // ── PATCH — partial update (only send fields to change) ────────────────
    // PATCH /api/posts/42
    // Body: { "title": "Updated Title" }  — only the fields to update
    [HttpPatch("{id:int}")]
    [ProducesResponseType(typeof(PostDto), 200)]
    [ProducesResponseType(404)]
    public async Task<ActionResult<PostDto>> Update(
        int id, UpdatePostRequest request, CancellationToken ct)
    {
        var post = await service.UpdateAsync(id, request, ct);
        return post is null ? NotFound() : Ok(post);
    }
}
Note: The difference between PUT and PATCH is about completeness. A PUT request must contain the complete representation of the resource — any field not included is set to null or default. If you PUT { "title": "New Title" } without including the body, the body is cleared. A PATCH request contains only the fields to change — other fields remain unchanged. For most real-world update operations, PATCH is more practical: you only send what changed. PUT is used when you genuinely need to guarantee a resource matches a complete specification.
Tip: The simplest PATCH pattern uses a DTO with nullable properties — only non-null values are applied. public record UpdatePostRequest(string? Title, string? Body, string? Slug) — if Title is null, do not update the title; if non-null, update it. This avoids the complexity of JSON Patch (RFC 6902) which requires an array of operation objects. For most CRUD APIs, the nullable DTO pattern is sufficient and much simpler to implement and document.
Warning: Never trust the ID from the request body for PUT/PATCH. If the route is PUT /api/posts/42 and the body contains { "id": 99, "title": "..." }, use the route ID (42), not the body ID (99). The route ID is the authoritative identifier — the body ID could be a client mistake or an injection attempt to modify a different resource. Design your update request DTOs without an ID field, or ignore it in the service layer if it exists.

JSON Patch (RFC 6902) — Standards-Based PATCH

// dotnet add package Microsoft.AspNetCore.Mvc.NewtonsoftJson
// (JsonPatchDocument requires Newtonsoft.Json in ASP.NET Core)

// ── JSON Patch request body ───────────────────────────────────────────────
// PATCH /api/posts/42
// Content-Type: application/json-patch+json
// [
//   { "op": "replace", "path": "/title", "value": "New Title" },
//   { "op": "remove",  "path": "/subtitle" },
//   { "op": "add",     "path": "/tags/-", "value": "dotnet" }
// ]

[HttpPatch("{id:int}")]
public async Task<ActionResult<PostDto>> Patch(
    int id,
    [FromBody] JsonPatchDocument<UpdatePostRequest> patchDoc,
    CancellationToken ct)
{
    var post = await service.GetByIdAsync(id, ct);
    if (post is null) return NotFound();

    var patchTarget = post.ToUpdateRequest();
    patchDoc.ApplyTo(patchTarget, ModelState);   // apply patch operations

    if (!ModelState.IsValid)
        return BadRequest(ModelState);

    var updated = await service.UpdateAsync(id, patchTarget, ct);
    return Ok(updated);
}

Common Mistakes

Mistake 1 — Using PUT when only updating some fields (clears non-sent fields)

❌ Wrong — Angular sends PUT with only changed fields; all other fields silently cleared on server.

✅ Correct — use PATCH for partial updates; PUT only when replacing the complete resource.

Mistake 2 — Using the ID from the request body instead of the route (security issue)

❌ Wrong — body ID could differ from route ID; wrong resource modified:

var post = await service.UpdateAsync(request.Id, request, ct);  // uses body ID — wrong!

✅ Correct — always use the route parameter ID: service.UpdateAsync(id, request, ct).

🧠 Test Yourself

A PUT request to /api/posts/42 sends only { "title": "New Title" } without including the post body. What happens to the body field?