HTTP Methods
Each HTTP method is a promise. GET never changes data, PUT and DELETE are safe to retry, POST is not. ShopSmart’s mobile app retries requests on bad networks, so keeping these promises matters. We implement all five on one resource.
On this page · 8 min · Beginner
On this page
- In simple words
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 8 min
- Difficulty
- Beginner
- Updated
- Oct 29, 2026
Related tutorials
In simple words#
HTTP methods are verbs for a library: GET (read a book), POST (donate a new book), PUT (replace a book), PATCH (fix a page), DELETE (remove a book).
- GET
- Read — no changes.
- POST / PUT / PATCH
- Create / replace / partly update.
- Idempotent
- Repeating it has the same effect (GET, PUT, DELETE).
Learning objectives#
- Implement GET, POST, PUT, PATCH and DELETE actions
- Explain safe and idempotent methods
- Do partial updates with PATCH
- Recognise 405 Method Not Allowed
Explanation#
[HttpGet] reads (200), [HttpPost] creates (201 + Location), [HttpPut] replaces the whole resource (204 or 200), [HttpPatch] changes some fields (200 or 204), [HttpDelete] removes (204).
Safe methods (GET, HEAD, OPTIONS) do not change data. Idempotent methods (GET, PUT, DELETE) have the same effect whether called once or five times — so clients can retry them after a timeout. POST is neither: two POSTs create two orders.
PUT expects the complete representation; validation should require all fields. PATCH sends only what changes — in .NET 9 a simple approach is a DTO with nullable properties (null = “not sent”). JSON Patch documents are another option.
If a URL exists but not for that method (DELETE /api/products), ASP.NET Core answers 405 Method Not Allowed with an Allow header listing what is supported.
For non-idempotent operations that must be retry-safe (payments, orders), accept an Idempotency-Key header and remember which keys were already processed.
Syntax#
[HttpGet("{id:int}")] public ActionResult<ProductDto> Get(int id) => …;
[HttpPost] public ActionResult<ProductDto> Create(CreateProductDto dto) => …; // 201
[HttpPut("{id:int}")] public IActionResult Replace(int id, ReplaceProductDto dto) => …; // 204
[HttpPatch("{id:int}")] public ActionResult<ProductDto> Update(int id, PatchProductDto dto) => …;
[HttpDelete("{id:int}")] public IActionResult Delete(int id) => …; // 204
Code example: All five methods on /api/products#
// Program.cs
using System.Net.Http.Json;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders(); // keep the demo output clean
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Urls.Add("http://127.0.0.1:5203");
await app.StartAsync();
using var http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5203") };
async Task Send(HttpMethod method, string path, object? body = null)
{
var request = new HttpRequestMessage(method, path) { Content = body is null ? null : JsonContent.Create(body) };
var response = await http.SendAsync(request);
var allow = response.Content.Headers.Allow.Count > 0 ? quot; (Allow: {string.Join(", ", response.Content.Headers.Allow)})" : "";
Console.WriteLine(quot;{method,-6} {path,-18} {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}".TrimEnd() + allow);
}
await Send(HttpMethod.Post, "/api/products", new { name = "Webcam", price = 2199, stock = 5 });
await Send(HttpMethod.Post, "/api/products", new { name = "Webcam", price = 2199, stock = 5 }); // POST twice = two products
await Send(HttpMethod.Patch, "/api/products/1", new { price = 1999 }); // only the price
await Send(HttpMethod.Put, "/api/products/1", new { name = "HD Webcam", price = 2499, stock = 8 });
await Send(HttpMethod.Get, "/api/products/1");
await Send(HttpMethod.Delete, "/api/products/2");
await Send(HttpMethod.Delete, "/api/products/2"); // same state, different response
await Send(HttpMethod.Delete, "/api/products"); // no DELETE on the collection
await app.StopAsync();
// Controllers/ProductsController.cs
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
public record ProductDto(int Id, string Name, decimal Price, int Stock);
public record SaveProductDto([Required] string Name, [Range(1, 1_000_000)] decimal Price, [Range(0, 10_000)] int Stock);
public record PatchProductDto(string? Name, decimal? Price, int? Stock); // null = "not sent"
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private static readonly Dictionary<int, ProductDto> Store = [];
private static int _nextId = 1;
[HttpGet]
public IEnumerable<ProductDto> GetAll() => Store.Values;
[HttpGet("{id:int}")]
public ActionResult<ProductDto> Get(int id) => Store.TryGetValue(id, out var p) ? p : NotFound();
[HttpPost] // not idempotent
public ActionResult<ProductDto> Create(SaveProductDto dto)
{
var product = new ProductDto(_nextId++, dto.Name, dto.Price, dto.Stock);
Store[product.Id] = product;
return CreatedAtAction(nameof(Get), new { id = product.Id }, product);
}
[HttpPut("{id:int}")] // idempotent: replaces everything
public IActionResult Replace(int id, SaveProductDto dto)
{
if (!Store.ContainsKey(id)) return NotFound();
Store[id] = new ProductDto(id, dto.Name, dto.Price, dto.Stock);
return NoContent();
}
[HttpPatch("{id:int}")] // partial update
public ActionResult<ProductDto> Update(int id, PatchProductDto dto)
{
if (!Store.TryGetValue(id, out var p)) return NotFound();
Store[id] = p with { Name = dto.Name ?? p.Name, Price = dto.Price ?? p.Price, Stock = dto.Stock ?? p.Stock };
return Store[id];
}
[HttpDelete("{id:int}")] // idempotent: the product stays deleted
public IActionResult Delete(int id) => Store.Remove(id) ? NoContent() : NotFound();
}
POST /api/products 201 {"id":1,"name":"Webcam","price":2199,"stock":5}
POST /api/products 201 {"id":2,"name":"Webcam","price":2199,"stock":5}
PATCH /api/products/1 200 {"id":1,"name":"Webcam","price":1999,"stock":5}
PUT /api/products/1 204
GET /api/products/1 200 {"id":1,"name":"HD Webcam","price":2499,"stock":8}
DELETE /api/products/2 204
DELETE /api/products/2 404 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"0HNPL7JP34I10:00000007"}
DELETE /api/products 405 (Allow: GET, POST)
(the traceId varies per request)This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- Two identical POSTs create products 1 and 2 — POST is not idempotent.
- PATCH sends only the price; Name and Stock keep their values.
- PUT replaces the whole product and returns 204 No Content; GET shows the result.
- The second DELETE of product 2 returns 404, but the server state is the same — DELETE is idempotent.
- DELETE on the collection URL is not mapped, so the API answers 405 with an Allow header.
Real-world use case#
ShopSmart’s mobile app uses PATCH to update a single cart quantity, PUT to save the full delivery address, and sends an Idempotency-Key with POST /api/orders so a retried checkout never creates two orders.
Common mistakes#
GET /api/products/7/delete can be triggered by crawlers, link previews or caches. Use DELETE.
A PUT body missing fields would, by definition, clear them. Use PATCH for partial changes.
For retries, “already gone” is fine. Clients can treat 404 on DELETE as success.
Best practices#
- 201 + Location for POST, 204 for PUT/DELETE
- PATCH DTOs with nullable fields
- Idempotency keys for retry-sensitive POSTs
- Let 405 tell clients what is allowed
Remember this#
Safe: GET. Idempotent: GET, PUT, DELETE.
PUT = replace all. PATCH = change some.
Wrong method on a valid URL → 405 + Allow.
Practice exercise#
Your task: Add HEAD support for GET /api/products/{id} by also marking the action with [HttpHead("{id:int}")], so clients can check existence without downloading the body.
Need a hint?
Show solution
// Controllers/ProductsController.cs
using Microsoft.AspNetCore.Mvc;
public record ProductDto(int Id, string Name);
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private static readonly ProductDto[] Products = [new(1, "Wireless Mouse")];
[HttpGet("{id:int}")]
[HttpHead("{id:int}")]
public ActionResult<ProductDto> Get(int id) =>
Products.FirstOrDefault(p => p.Id == id) is { } product ? product : NotFound();
}
Mini quiz#
Which method should a client use to change only the stock of a product?
Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions
My notes#
Saved in this browser.