1. Semantics: empty, 404, and 400
It is very easy to mix up these three scenarios, because on the surface they all look like “well... something did not work.” But for an API these are different stories, and each one must have its own status and its own response format. Imagine a library: GET /tasks is “show the catalogue,” while GET /tasks/{id} is “give me a specific book from the shelf by inventory number.” If there are no books in the catalogue, the library still exists. But if you ask for a book by number and it is not there, that is a different matter.
For a collection endpoint (in our case /api/v1/tasks), an empty result means only that there are no elements for the current request. This is not “resource not found,” because the resource “task collection” exists as a concept at all times: it just currently has zero elements, or zero elements on the selected page. Therefore, in a normal REST API this remains 200 OK and a valid PagedResponse, where items is an empty list.
404 Not Found in this world means that you addressed a specific resource, and it is not there. For example, GET /api/v1/tasks/{taskId} for a non-existent taskId. Or when you are hitting the wrong path altogether (the wrong endpoint).
400 Bad Request means that the request does not work as a request: you sent a negative page, page size 0, an enormous size, sorting by a disallowed field, the direction upsideDown, or page=abc. And here is the most important part: 400 does not mean “nothing was found,” it means the request is invalid.
To make this concrete, it helps to keep a small matrix in your head:
| Situation | Example request | What we return | Why this way |
|---|---|---|---|
| Valid request, but there are no elements | GET /api/v1/tasks?page=10&size=20 | 200 OK + PagedResponse with empty items | The collection exists, it is just empty on this page |
| Resource not found | GET /api/v1/tasks/{taskId} | 404 Not Found + ProblemDetail | The specific resource does not exist at that address |
| Request is invalid | GET /api/v1/tasks?size=0 or |
400 Bad Request + ProblemDetail | The client violated the parameter contract |
Notice that in the first row of the table we do not write “error,” even though it may be frustrating for the user to see an empty result. But the API is not about emotions; it is about predictability.
It is useful to keep this distinction strict from the start: the set of list parameters will keep growing, but the status semantics for the collection should not drift.
2. 200 OK and an empty items
An empty result is not a “special case that should be handled in a separate branch in the controller.” It is a normal execution path for a list endpoint. And this has a good practical benefit: the client can render an “empty state” (“there are no tasks yet”) and does not need to switch on emergency error-handling logic. An error is when the contract is violated, not when there is no data.
In our contract, an “empty result” should look just as stable as a “non-empty result”: the response body is the same PagedResponse<TaskSummaryResponse>, only items is empty. And here comes an important discipline: items must be a list, not null. null is a gift to the client in the form of an NPE/Cannot read property 'length' of null and strange if statements on the front end. An empty list means “I have nothing to show,” while null means “I do not even know what this field is.”
There are two “empty” scenarios that are important to distinguish in your head (but not necessarily by status):
The first is an empty collection. This is when totalElements == 0, meaning there are no tasks at all. Then items is empty, totalElements = 0, and totalPages according to the rule we chose will also be 0. Yes, some people make totalPages=1 even with zero elements, but that turns metadata into philosophy. In our educational project the logic is simpler: “there are no pages because there are no elements.”
{
"items": [],
"page": 0,
"size": 20,
"totalElements": 0,
"totalPages": 0,
"sort": "updatedAt,desc"
}
The second is an empty page within a non-empty collection. This is when elements do exist in principle, but the selected page is “far beyond the horizon.” For example, we have only 7 tasks, size=5, which means there are 2 pages (0 and 1), but the client requested page=10. This is not a contract error: page does not violate the range (we did not introduce a max page). The request is valid; the result is simply empty.
{
"items": [],
"page": 10,
"size": 5,
"totalElements": 7,
"totalPages": 2,
"sort": "updatedAt,desc"
}
Why is this useful? Because the client can honestly understand the situation without guessing. It sees totalPages=2, page=10 — so it requested too far and needs to stop paging. If we returned 404 or 400 instead, the client would have to decide whether this is an “error,” “no data,” or “path not found.”
It is also important that the response reflect the actually applied page, size, and sort. If the client did not send sort, and we applied the default, then the response should contain sort: "updatedAt,desc". This is not “duplication”; it is a way to remove ambiguity: the client sees not only the data, but also the context and the rules used to obtain it.
3. 400 Bad Request for page/size/sort
An empty result is about data. 400 Bad Request is about the contract. And this is where the favorite beginner mistakes begin: “let us just adjust the input parameters and pretend everything is fine.” For example, if the client sends size=1000, the server quietly turns it into size=100 so as not to “complain.” That sounds friendly, but in reality you are creating hidden magic, because the client does not understand what it is doing wrong, and then it shows up as bugs like “we sometimes get the wrong number of elements back.”
We agreed on a simple rule: a missing parameter means use the default. An invalid parameter means return 400 through our existing error contract based on ProblemDetail.
At the list-endpoint input, there is already an explicit TaskSearchCriteria: page only allows values >= 0, size is in the range 1..100, and sort goes through a separate format and whitelist check. The key is not to repeat all the code here, but to keep the meaning clear: null means “the parameter was not provided,” and any range or whitelist violation is invalid input. This is exactly the sort of DTO that can later survive the expansion of the list with new filtering conditions: the contents of the criteria change, not the endpoint’s status logic.
Now the scenarios look like this. If page was not sent, it is null, and our normalization safely turns it into 0. If page=-1, that is already a contract violation, and it must become 400. If size=0, that is also 400. If size=1000, it is 400. We do not “fix” anything silently, because the contract must be clear.
With sort, things are a bit more interesting: Bean Validation does not solve everything here, because we parse the field,dir format manually, and we also check the whitelist manually. So an error like “unsupported sort field” is converted into our domain “invalid input” exception (for example, InvalidInputException), and then we return ProblemDetail through @ControllerAdvice. An unknown field, an invalid direction, or a broken format is 400, not an empty list and not a silent fallback to the default.
The key idea of this lecture: an error in sort is not “nothing was found” and not “let us set the default and stay quiet”. It is an invalid request, and it must lead to 400 in a unified error format.
It is also important to remember the types. If the client sent page=abc, that is not “@Min failed,” because Bean Validation may never get that far: Spring first tries to convert the query parameter into a number, and that fails during binding/type conversion. But from the perspective of the external contract, this is still invalid input. Therefore our global error layer must turn both validation errors and type mismatches, as well as “unsupported sort,” into a predictable ProblemDetail.
An example response (simplified, but correct in meaning) for size=0:
{
"type": "about:blank",
"title": "Invalid request",
"status": 400,
"detail": "Validation failed",
"instance": "/api/v1/tasks",
"code": "INVALID_INPUT",
"fieldErrors": [
{
"field": "size",
"message": "must be greater than or equal to 1"
}
]
}
And an example for an “unsupported sort field”:
{
"type": "about:blank",
"title": "Invalid request",
"status": 400,
"detail": "Unsupported sort field: hackerField",
"instance": "/api/v1/tasks",
"code": "INVALID_INPUT"
}
Notice how this helps the client. In the first case it can highlight the specific size field. In the second, it can show the user “you cannot sort by this field” or fix the UI (for example, remove the forbidden field from the list). If we returned 200 and an empty list, the client would not understand anything at all.
4. .http examples for 200/400/404
When you design semantics, it is very useful to check them not only with your eyes as a developer, but also with the client’s eyes. In our project, it is convenient to do this through .http files (or Postman), because there you can clearly see: request → status → body. And it immediately becomes obvious where everything is the same and unclear, and where the client actually gets a signal.
Here is an example of a valid request for a “far” page. It should be successful, but with empty items:
### Empty page: request is valid, items are empty
GET http://localhost:8080/api/v1/tasks?page=10&size=5&sort=updatedAt,desc
Accept: application/json
# Expected: 200 OK (even if items = [])
If tasks exist, you will see 200 OK and items: [], plus honest totalElements/totalPages. If there are no tasks at all, you will still see 200 OK; only totalElements will be 0.
Now an example of an invalid page size. This is no longer “empty,” but “the contract was broken”:
### Invalid size: must be 1..100
GET http://localhost:8080/api/v1/tasks?page=0&size=0&sort=updatedAt,desc
Accept: application/json
# Expected: 400 Bad Request + ProblemDetail with details for the size field
We expect 400 Bad Request and Content-Type: application/problem+json (or an equivalent Problem Details format), and inside it your code=INVALID_INPUT and details.
And the third example is an error in sort. Here it is especially important not to “swallow” a typo, because otherwise the client will spend years accidentally sorting “as it happened.”
### Invalid sort field: not in whitelist
GET http://localhost:8080/api/v1/tasks?page=0&size=20&sort=hackerField,asc
Accept: application/json
# Expected: 400 Bad Request, because sort violates the whitelist
We expect 400 Bad Request, and in detail a clear message that the field is not supported.
That is how a mature collection should work: the set of parameters may grow, but the distinction between 200/400/404 stays the same.
To emphasize the difference from 404 once more, it is useful to compare this with a detail endpoint:
### Not found single resource
GET http://localhost:8080/api/v1/tasks/00000000-0000-0000-0000-000000000000
Accept: application/json
# Expected: 404 Not Found, because we are addressing a specific resource by id
This is already 404 Not Found, because a specific task resource is being addressed. And that is a different class of scenario from “the list is empty.”
If you keep these four requests side by side, the correct logic will lock into your head very quickly: a collection endpoint always returns 200 for a valid request, even if there is “nothing”; 400 is only for contract errors in the request; 404 is for the absence of a specific resource.
5. Typical mistakes in list semantics
The mistakes in this topic are tricky because they rarely look like “the application crashed.” Usually everything works, but the client lives in a world of unstated assumptions. So it is better to catch these moments now, while the project is educational, rather than when you have three clients, five integrations, and one very nervous product owner.
Mistake #1: returning 404 Not Found for an empty list.
This is the most common confusion. 404 means that there is no resource at the given URI. But /api/v1/tasks is a collection resource, and it exists at all times. If there are no tasks, this is not “resource not found,” it is “the collection is empty.” The client only gets worse off from 404, because it will trigger an error flow instead of an empty state.
Mistake #2: returning 204 No Content for an empty list.
Sometimes it seems logical: “there is nothing, so no content.” But our list contract is a PagedResponse, and even with empty items the client needs metadata (page, size, totalPages, sort). 204 forbids a response body in spirit, which means you break your own contract and force the client to guess.
Mistake #3: silently fixing invalid size/sort instead of returning 400.
If the client sent size=1000, and the server “fixed” it to 100, the client will keep sending 1000. The same applies to sort: a silent fallback to the default turns a typo into a “hidden mode of operation.” A month later no one remembers why the results “are sorted wrong.” It is much more honest and useful to return 400 and make the client conform to the contract.
Mistake #4: setting items = null instead of an empty list.
Even if returning null is easier for you in Java, for a JSON contract this is almost always wrong. The client will be forced to write extra checks, and in some languages/frameworks it will also cause crashes. An empty list is the normal data shape, and it should be stable.
Mistake #5: calculating totalElements from the size of the current page.
This breaks the meaning of pagination. The metadata must describe the full result set, not just the current slice. Otherwise the client will not be able to tell how many pages there are in total, and will not be able to build proper navigation. In our algorithm, totalElements comes from the full (sorted) collection, and items comes from the slice.
GO TO FULL VERSION