Skip to content

GET vs POST in ASP.NET Core Web API

GET and POST are the two HTTP methods you will use most. In one sentence: GET reads data, POST sends data to the server to create something. But the differences go further: where the data travels, whether the request can be repeated safely, whether it can be cached, and what the server should reply.

This lesson uses the Products API from What is Web API. Every request and response below was captured from that running API (only the Date and Server headers are left out).

Think of a library. Asking the librarian "show me the books by this author" is a GET: you only look, nothing changes, and you can ask the same question ten times. Handing in a form to register a new member is a POST: it creates something new, and if you hand in the same form twice, you may get two memberships.

GET: read data

A GET request asks for a resource. All its input travels in the URL: in the route (/api/products/2) or in the query string (?category=accessories). A GET request has no body.

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    // GET api/products?category=Accessories&maxPrice=1000
    [HttpGet]
    public IActionResult GetAll(string? category, decimal? maxPrice)     // read from the query string
    {
        var products = _store.GetAll();
        if (!string.IsNullOrEmpty(category))
            products = products.Where(p => p.Category.Equals(category, StringComparison.OrdinalIgnoreCase));
        if (maxPrice.HasValue)
            products = products.Where(p => p.Price <= maxPrice.Value);
        return Ok(products);
    }

    // GET api/products/2
    [HttpGet("{id:int}")]
    public IActionResult GetById(int id)                                // read from the route
    {
        var product = _store.Find(id);
        return product is null ? NotFound() : Ok(product);
    }
}

[ApiController] knows that simple parameters like string, int and decimal come from the route or query string, so [FromQuery] and [FromRoute] are optional here.

GET /api/products?category=accessories&maxPrice=1000
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

[{"id":1,"name":"Wireless Mouse","category":"Accessories","price":799,"stock":120}]
GET /api/products/2
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"id":2,"name":"Mechanical Keyboard","category":"Accessories","price":3499,"stock":45}

Characteristics of GET

  • Safe: it must not change anything on the server. Reading a product never modifies it.
  • Idempotent: sending the same GET 1 time or 100 times has the same effect (none).
  • Cacheable: browsers, CDNs and proxies can cache GET responses, which makes them fast.
  • Bookmarkable and shareable: the whole request is in the URL, so you can copy, bookmark or share it.
  • Visible: the URL, including the query string, appears in browser history, server logs and proxy logs. Never put passwords, tokens or personal data in a GET URL.
  • Limited size: URLs have practical length limits (browsers and servers typically accept a few thousand characters; Kestrel's default request-line limit is 8 KB).

POST: send data to create a resource

A POST request sends data in the request body, usually as JSON, and asks the server to process it. Most often that means "create a new resource".

The data the client sends is described by a separate class, with validation rules:

using System.ComponentModel.DataAnnotations;

// What the client sends to create a product (no Id: the server generates it)
public class ProductDto
{
    [Required, StringLength(100)]
    public string? Name { get; set; }

    [Required]
    public string? Category { get; set; }

    [Range(0.01, 1_000_000)]
    public decimal Price { get; set; }

    [Range(0, 100_000)]
    public int Stock { get; set; }
}
// POST api/products
[HttpPost]
public IActionResult Create(ProductDto dto)                // complex type: read from the BODY
{
    var product = _store.Add(new Product
    {
        Name = dto.Name!, Category = dto.Category!, Price = dto.Price, Stock = dto.Stock
    });

    // 201 Created + a Location header pointing to the new product
    return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
POST /api/products
Content-Type: application/json

{ "name": "USB-C Hub", "category": "Accessories", "price": 1899, "stock": 60 }
Response
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5288/api/Products/4

{"id":4,"name":"USB-C Hub","category":"Accessories","price":1899,"stock":60}
  • The status is 201 Created, not 200. This is the correct code when something new was created.
  • The Location header tells the client the URL of the new resource. CreatedAtAction builds it from the GetById route, so the client can immediately GET /api/Products/4.
  • The body returns the created product, including the id the server generated.

POST is not idempotent

Let's send exactly the same POST again:

Second identical POST /api/products
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5288/api/Products/5

{"id":5,"name":"USB-C Hub","category":"Accessories","price":1899,"stock":60}

A second product (id 5) was created. Repeating a POST can repeat its effect: two products, two orders, two payments. That's why browsers warn "Confirm form resubmission" when you refresh after a POST, and why payment APIs often accept an idempotency key header, so that a retried request is not charged twice.

Validation happens automatically

With [ApiController], invalid data never reaches the action:

POST /api/products
Content-Type: application/json

{ "name": "", "price": 0 }
Response
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1",
 "title":"One or more validation errors occurred.",
 "status":400,
 "errors":{"Name":["The Name field is required."],
           "Price":["The field Price must be between 0.01 and 1000000."],
           "Category":["The Category field is required."]},
 "traceId":"00-1468a032..."}

The Content-Type header matters

The server needs to know the format of the body. If the client forgets Content-Type: application/json (here, it sent form data instead):

POST /api/products with body "name=USB-C Hub" and no JSON content type
HTTP/1.1 415 Unsupported Media Type
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.16","title":"Unsupported Media Type","status":415,"traceId":"00-597c605e..."}

A 415 error almost always means a missing or wrong Content-Type header. A GET request never has this problem, because it has no body.

GET vs POST: comparison table

PointGETPOST
PurposeRead / retrieve dataSend data; usually create a new resource
Where data travelsURL: route and query stringRequest body (JSON, form data, files)
Request bodyNoneYes
Changes server data?No (safe)Yes
Idempotent?Yes: repeating has no extra effectNo: repeating can create duplicates
Cacheable?YesNot by default
Bookmark / share the URL?YesNo
Visible in browser history and logs?Yes, including parametersThe URL yes, the body no
Data sizeLimited by URL lengthLarge (Kestrel's default body limit is about 30 MB, configurable)
Data typesText only (in the URL)Any: JSON, XML, files, binary
ASP.NET Core attribute[HttpGet][HttpPost]
Parameter source[FromRoute], [FromQuery][FromBody] (or [FromForm] for forms and files)
Typical success code200 OK (404 if not found)201 Created (with Location header)

Is POST more secure than GET?

Not really, and this is a common interview trap. POST hides the data from the URL, so it doesn't appear in browser history, bookmarks or server access logs. But the data is not encrypted by POST: anyone who can see the traffic can read the body just as easily as the URL. Real security comes from HTTPS, which encrypts both the URL and the body, plus authentication and authorization. Still, sensitive data such as passwords should always go in a POST body, never in a GET URL.

Calling GET and POST from clients

From C# with HttpClient

using System.Net.Http.Json;

var http = new HttpClient { BaseAddress = new Uri("http://localhost:5288/") };

// GET
List<Product>? products = await http.GetFromJsonAsync<List<Product>>("api/products?category=accessories");

// POST
var newProduct = new { Name = "USB-C Hub", Category = "Accessories", Price = 1899m, Stock = 60 };
HttpResponseMessage response = await http.PostAsJsonAsync("api/products", newProduct);   // sets Content-Type for you

Console.WriteLine(response.StatusCode);          // Created
Console.WriteLine(response.Headers.Location);    // http://localhost:5288/api/Products/4

From JavaScript with fetch

// GET
const products = await fetch("/api/products?category=accessories").then(r => r.json());

// POST
const response = await fetch("/api/products", {
    method: "POST",
    headers: { "Content-Type": "application/json" },     // without this: 415
    body: JSON.stringify({ name: "USB-C Hub", category: "Accessories", price: 1899, stock: 60 })
});
console.log(response.status);                             // 201

When to use which

  • Use GET for reading: lists, details, search with a few filters, reports.
  • Use POST to create resources: register a user, place an order, upload a file.
  • Use POST for actions that don't fit the other methods: POST /api/orders/15/cancel, POST /api/auth/login.
  • Use POST for a "search" only when the criteria are too large or complex for a URL, or contain sensitive data. Document clearly that it doesn't change anything.
  • Never use GET to change data (GET /api/products/2/delete is wrong): crawlers, link previews and browser prefetching can trigger GET requests on their own.

Interview questions

1. What is the difference between GET and POST?

GET retrieves data and sends its parameters in the URL; it is safe, idempotent and cacheable. POST sends data in the request body to create or process something on the server; it is not idempotent, not cached by default, and can carry large or binary data.

2. What does idempotent mean? Is POST idempotent?

An operation is idempotent if repeating it gives the same result as doing it once. GET, PUT and DELETE are idempotent. POST is not: sending the same POST twice usually creates two resources.

3. Can a GET request have a body?

The HTTP specification gives a GET body no meaning, and many servers, proxies and client libraries ignore or reject it. Send GET parameters in the route or query string; if the data is too big or complex for a URL, use POST.

4. What status code should a POST that creates a resource return?

201 Created, with a Location header pointing to the new resource and usually the created resource in the body. In ASP.NET Core, use CreatedAtAction or CreatedAtRoute.

5. Why do I get 415 Unsupported Media Type on a POST?

The request body has a Content-Type the action doesn't accept, most often because the client didn't send Content-Type: application/json with a JSON body.

6. Is POST more secure than GET?

Only slightly: POST keeps data out of URLs, history and access logs. It doesn't encrypt anything. Use HTTPS for encryption, and never put secrets in GET query strings.

7. How does ASP.NET Core know where to read parameters from?

With [ApiController], complex types are bound from the body ([FromBody]), and simple types from the route or query string. You can always be explicit with [FromRoute], [FromQuery], [FromBody], [FromForm] and [FromHeader].

Summary

  • GET reads data; parameters go in the URL; it is safe, idempotent, cacheable and bookmarkable.
  • POST sends data in the body, usually to create a resource; it is not idempotent and not cached.
  • A successful create returns 201 Created with a Location header (CreatedAtAction).
  • [ApiController] validates POST bodies automatically (400) and needs the right Content-Type (otherwise 415).
  • POST is not encryption; use HTTPS, and keep secrets out of GET URLs.

Next: PUT vs PATCH in Web API.