JSON Patch with System.Text.Json (.NET 10)
Accept application/json-patch+json with the System.Text.Json JsonPatchDocument<T>, apply it safely to a DTO, validate the result, or pick merge patch instead.
[HttpPatch("{id}")]
public IActionResult Patch(int id, [FromBody] JsonPatchDocument<OrderPatch> patch)
{
patch.ApplyTo(dto, e => ModelState.AddModelError(e.Operation?.path ?? "", e.ErrorMessage));
}JSON Patch (RFC 6902) is a list of operations against a JSON document, sent with the media type application/json-patch+json:
[
{ "op": "test", "path": "/status", "value": "Pending" },
{ "op": "replace", "path": "/city", "value": "York" },
{ "op": "add", "path": "/notes/-", "value": "Leave at the door" }
]Operations are add, remove, replace, move, copy and test; paths are JSON Pointers and /- appends to an array. test makes the patch conditional, which gives you a compare-and-set on a single field.
The System.Text.Json implementation
In .NET 10 JSON Patch is available on System.Text.Json through the Microsoft.AspNetCore.JsonPatch.SystemTextJson package (namespace of the same name). Its JsonPatchDocument<T> deserialises with System.Text.Json, so it binds as a [FromBody] parameter in controllers and as a body parameter in minimal APIs, with no Newtonsoft.Json and no AddNewtonsoftJson(). The older Microsoft.AspNetCore.JsonPatch package depended on Newtonsoft.Json; new code should use the System.Text.Json one.
The request must carry Content-Type: application/json-patch+json; plain application/json is rejected with 415. Paths match property names case-insensitively, so /city and /City both work.
ApplyTo is not atomic
patch.ApplyTo(target) applies operations in order and throws JsonPatchException at the first failure, leaving the earlier ones applied. patch.ApplyTo(target, error => ...) reports each failure to your callback and carries on with the rest. Either way a half-applied object exists, so the safe sequence is:
- Load the entity and copy the patchable fields into a patch DTO.
- Apply the patch to the DTO, collecting errors (unknown path, bad index, failed
test, wrong value type). - If there were errors, return them as a validation problem.
- Validate the DTO (
TryValidateModelin a controller). A patch canremovea required field or replace it with an empty string, and model validation never ran on the patch document itself. - Map the DTO onto the entity and save.
Security
A patch may target any public settable property of T. Patching an entity lets a client replace /creditLimit; patching a DTO limits it to what you exposed. Limiting the fields does not replace checking that the caller owns the resource: load it, compare its owner with the caller's claim, and answer 404 on a mismatch. Also cap the operation count and array growth: a body of 50,000 add operations is cheap to send and expensive to apply.
When merge patch is simpler
| Approach | Body | Best for |
|---|---|---|
| PUT | The full resource | Small resources, simple clients |
| JSON Merge Patch (RFC 7396) | The changed fields; null removes | Forms and most partial updates |
| JSON Patch (RFC 6902) | An operation list | Array edits by index, conditional updates, audit of exact operations |
Most front ends naturally produce merge patches. Reach for JSON Patch when you need test or precise array operations.
Example
using System.Collections.Concurrent;
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.JsonPatch.SystemTextJson;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddAuthentication().AddJwtBearer(); // settings from Authentication:Schemes:Bearer
builder.Services.AddAuthorization();
builder.Services.AddControllers();
builder.Services.AddSingleton<OrderRepository>();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
public sealed class Order
{
public int Id { get; init; }
public string CustomerId { get; init; } = "";
public decimal Total { get; set; }
public string Status { get; set; } = "Pending";
public string Street { get; set; } = "";
public string City { get; set; } = "";
public string Postcode { get; set; } = "";
public List<string> DeliveryNotes { get; set; } = [];
}
// What a client may patch. Total and CustomerId are deliberately absent.
public sealed class ShippingPatch
{
[Required, StringLength(100, MinimumLength = 3)] public string Street { get; set; } = "";
[Required, StringLength(60, MinimumLength = 2)] public string City { get; set; } = "";
[Required, RegularExpression("^[A-Z0-9 ]{5,8}$")] public string Postcode { get; set; } = "";
[MaxLength(5)] public List<string> DeliveryNotes { get; set; } = [];
public string Status { get; set; } = ""; // readable for "test", rejected if changed
}
public sealed class OrderRepository
{
private readonly ConcurrentDictionary<int, Order> _orders = new()
{
[1001] = new Order { Id = 1001, CustomerId = "CUST-1042", Total = 79.90m,
Street = "1 Mill Lane", City = "Leeds", Postcode = "LS1 4AP" },
};
public Order? Find(int id) => _orders.GetValueOrDefault(id);
}
[ApiController]
[Authorize]
[Route("api/orders")]
public sealed class OrderShippingController(OrderRepository orders) : ControllerBase
{
private const int MaxOperations = 20;
// PATCH /api/orders/1001/shipping
// Content-Type: application/json-patch+json
[HttpPatch("{id:int}/shipping")]
[Consumes("application/json-patch+json")]
public IActionResult PatchShipping(int id, [FromBody] JsonPatchDocument<ShippingPatch> patch)
{
if (patch.Operations.Count > MaxOperations)
return Problem(statusCode: StatusCodes.Status400BadRequest,
title: $"A patch may contain at most {MaxOperations} operations.");
// Another customer's order is a 404, not a 403, so ids cannot be probed.
if (orders.Find(id) is not { } order || order.CustomerId != User.FindFirstValue("customer_id"))
return NotFound();
if (order.Status is "Shipped" or "Delivered")
return Problem(statusCode: StatusCodes.Status409Conflict, title: "The order has already shipped.");
// 1. Copy into the DTO, never patch the entity directly.
var dto = new ShippingPatch
{
Street = order.Street, City = order.City, Postcode = order.Postcode,
DeliveryNotes = [.. order.DeliveryNotes], Status = order.Status
};
// 2. Apply, collecting every failed operation (unknown path, failed test, bad type).
patch.ApplyTo(dto, error =>
ModelState.AddModelError(error.Operation?.path ?? "patch", error.ErrorMessage));
if (!ModelState.IsValid) return ValidationProblem(ModelState);
// 3. Validate the patched result: "remove /city" or "replace /postcode" with junk lands here.
if (dto.Status != order.Status)
ModelState.AddModelError("/status", "Status cannot be changed through this endpoint.");
if (!TryValidateModel(dto) || !ModelState.IsValid) return ValidationProblem(ModelState);
// 4. Only now touch the entity.
order.Street = dto.Street;
order.City = dto.City;
order.Postcode = dto.Postcode;
order.DeliveryNotes = dto.DeliveryNotes;
return Ok(dto);
}
}
When to use it
- Let customers change the shipping address on a pending order with a test op on status, so a shipped parcel is never redirected.
- Append a delivery note with an add to /deliveryNotes/- without resending the whole order.
- Expose PATCH /api/products/{sku} as a merge patch for an admin form that only sends the fields the user edited.
More examples
The same endpoint as a minimal API
Minimal APIs bind <code>JsonPatchDocument<T></code> from a body sent as <code>application/json-patch+json</code> and reject <code>application/json</code> with 415, exactly like controllers. There is no <code>ModelState</code>, so errors go into a dictionary that <code>Results.ValidationProblem</code> turns into the standard 400 body. The stored object is replaced only after the copy passes both the patch and validation.
using System.Collections.Concurrent;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.JsonPatch.SystemTextJson;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
var app = builder.Build();
var preferences = new ConcurrentDictionary<string, CustomerPreferences>
{
["CUST-1042"] = new() { Language = "en-GB", MarketingEmails = false, Channels = ["email"] },
};
app.MapPatch("/api/customers/{id}/preferences", (string id, JsonPatchDocument<CustomerPreferences> patch) =>
{
if (!preferences.TryGetValue(id, out var current)) return Results.NotFound();
var copy = new CustomerPreferences
{
Language = current.Language, MarketingEmails = current.MarketingEmails, Channels = [.. current.Channels]
};
var errors = new Dictionary<string, string[]>();
patch.ApplyTo(copy, e => errors[e.Operation?.path ?? "patch"] = [e.ErrorMessage]);
var results = new List<ValidationResult>();
if (!Validator.TryValidateObject(copy, new ValidationContext(copy), results, validateAllProperties: true))
foreach (var r in results)
errors[r.MemberNames.FirstOrDefault() ?? "patch"] = [r.ErrorMessage ?? "Invalid value."];
if (errors.Count > 0) return Results.ValidationProblem(errors);
preferences[id] = copy;
return Results.Ok(copy);
});
app.Run();
public sealed class CustomerPreferences
{
[Required, RegularExpression("^[a-z]{2}-[A-Z]{2}$")] public string Language { get; set; } = "en-GB";
public bool MarketingEmails { get; set; }
[MaxLength(3)] public List<string> Channels { get; set; } = [];
}
Building and sending a patch from a .NET client
The typed builder turns expressions into paths such as <code>/City</code> and <code>/DeliveryNotes/-</code>; the server matches them case-insensitively, so they work against camelCase APIs. The leading <code>test</code> operation makes the change conditional: if a warehouse worker already moved the order to Packed, the patch fails with a validation error instead of rewriting the address of a parcel that is already on a lorry.
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.AspNetCore.JsonPatch.SystemTextJson;
using var http = new HttpClient { BaseAddress = new Uri("https://orders.internal.example.com") };
var patch = new JsonPatchDocument<ShippingPatch>()
.Test(p => p.Status, "Pending") // fail if the order moved on meanwhile
.Replace(p => p.City, "York")
.Replace(p => p.Postcode, "YO1 7HH")
.Add(p => p.DeliveryNotes, "Leave with reception");
var content = new StringContent(JsonSerializer.Serialize(patch));
content.Headers.ContentType = new MediaTypeHeaderValue("application/json-patch+json");
using var response = await http.PatchAsync("/api/orders/1001/shipping", content);
Console.WriteLine($"{(int)response.StatusCode}: {await response.Content.ReadAsStringAsync()}");
public sealed class ShippingPatch
{
public string Street { get; set; } = "";
public string City { get; set; } = "";
public string Postcode { get; set; } = "";
public List<string> DeliveryNotes { get; set; } = [];
public string Status { get; set; } = "";
}
Merge patch with JsonObject when JSON Patch is overkill
Merge patch needs to tell an absent field from an explicit <code>null</code>, which a typed DTO cannot do because both arrive as <code>null</code>. <code>JsonObject</code> keeps that difference: <code>TryGetPropertyValue</code> returns false for an absent key and true with a <code>null</code> node for an explicit null. The allow-list blocks clients from patching fields such as <code>sku</code>. Values are read with <code>JsonValue.TryGetValue<T></code>, so <code>{"price": "cheap"}</code> becomes a 400 validation problem; <code>GetValue<T></code> throws <code>InvalidOperationException</code> for a wrongly typed node, which would surface as a 500.
using System.Text.Json.Nodes;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
var app = builder.Build();
var product = new Product("KETTLE-BLUE", "Blue kettle", 39.95m, "Fast-boil 1.7 l kettle");
// PATCH /api/products/KETTLE-BLUE {"price": 34.95, "description": null}
app.MapPatch("/api/products/{sku}", (string sku, JsonObject changes) =>
{
if (sku != product.Sku) return Results.NotFound();
var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "name", "price", "description" };
var unknown = changes.Select(p => p.Key).Where(k => !allowed.Contains(k)).ToArray();
if (unknown.Length > 0)
return Results.ValidationProblem(unknown.ToDictionary(k => k, _ => new[] { "This field cannot be patched." }));
// Absent keys are left alone; a key set to null clears the value. TryGetValue turns a
// wrongly typed value into a 400; GetValue would throw InvalidOperationException (a 500).
var errors = new Dictionary<string, string[]>();
var name = product.Name;
var price = product.Price;
var description = product.Description;
if (changes.TryGetPropertyValue("name", out var nameNode))
{
if (nameNode is JsonValue v && v.TryGetValue<string>(out var s) && s.Length > 0) name = s;
else errors["name"] = ["Must be a non-empty string."];
}
if (changes.TryGetPropertyValue("price", out var priceNode))
{
if (priceNode is JsonValue v && v.TryGetValue<decimal>(out var p) && p > 0) price = p;
else errors["price"] = ["Must be a positive number."];
}
if (changes.TryGetPropertyValue("description", out var descriptionNode))
{
if (descriptionNode is null) description = null;
else if (descriptionNode is JsonValue v && v.TryGetValue<string>(out var d)) description = d;
else errors["description"] = ["Must be a string or null."];
}
if (errors.Count > 0) return Results.ValidationProblem(errors);
product = product with { Name = name, Price = price, Description = description };
return Results.Ok(product);
});
app.Run();
public record Product(string Sku, string Name, decimal Price, string? Description);
Discussion