FastAPI Query Parameters: Validate skip and limit
Add typed, bounded skip and limit query parameters to a GET route and apply them to a collection.
Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons
Video lesson: FastAPI Query Parameters: Validate skip and limit
The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube
Jump to a chapter
Read the video transcript
Select a timestamp to watch that moment on YouTube.
- 00:00
Suppose a client requests /items/?skip=1&limit=2 from an API with four items. The expected page contains the second and third items. The values after the question mark are query parameters. FastAPI can parse their text into integers, apply numeric bounds, and pass valid values into the route function. This tutorial connects that HTTP behavior to the ordinary Python slice that selects a page. We will deliberately start with a slice that ignores the offset, then build a GET endpoint using Query for a nonnegative skip and a limited page size. The related FastAPI request-body lesson handles JSON sent in a POST body.
- 00:43
Here the data comes from the URL, and our local TestClient will send actual requests without deploying a server. A query parameter is useful for selecting a view of a resource without changing it. GET requests like this are easy to bookmark and test. We will use a small fixed list so the effect of each parameter is visible immediately. The broken function takes skip and limit but returns items up to limit, starting from zero every time. For skip one and limit two, it gives the first two items rather than the second and third. This is a logic error even if the HTTP layer correctly parsed the query values.
- 01:24
It also shows why a parameter name in a function signature does not make an endpoint correct by itself. Think of skip as the slice start. The stop index is skip plus limit, not limit alone. With a list of four items, slice one through three produces the requested pair. An offset beyond the end naturally returns an empty list. We will check input ranges separately so a negative skip cannot accidentally invoke Python's from-the-end slicing behavior. Negative Python slice indexes have a special meaning: they count backward from the end. That is useful elsewhere, but it is not the intended meaning of a negative page offset.
- 02:06
Reject the value before slicing, either in the route contract or in pure application logic. The diagnostic run uses urllib.parse to show what the query string contains: skip and limit arrive as lists of strings. FastAPI uses the int annotations in our route to convert those text values before calling our function. That conversion is part of request validation, not something the list slice does. We choose defaults so the parameters are optional. Query with ge zero makes skip nonnegative. Query with ge one and le fifty bounds limit to one through fifty. A client that sends zero, a negative value, or nonnumeric text receives a
- 02:48
validation response, normally status 422. Without bounds, a caller could ask for an extremely large page. These constraints also appear in generated API documentation. The generated OpenAPI schema also tells a client which parameters are available, their defaults, and their limits. That documentation is strongest when the actual route behavior matches it. A bounded parameter that is never used in the slice would still be a functional bug. The fixed endpoint declares a FastAPI application, a four-item list, and a GET route at /items/. The route has two typed query parameters with defaults and bounds.
- 03:32
It returns ITEMS[skip:skip + limit]. TestClient sends a URL with skip one and limit two, and the response contains pad and clip. This is a local in-process test, not a public deployment. A production database should not fetch every row into Python and slice afterward. It should apply a stable ordering and page limits in the database query. This little list is intentionally small so the route and query behavior remain visible. For a larger changing collection, offset pagination may produce inconsistent pages and cursor pagination may be more suitable. TestClient runs the application in-process and is appropriate for a fast route test.
- 04:16
It still exercises FastAPI's parsing and validation, which a direct call to list_items would skip. Use both kinds of tests when the route has meaningful business logic. The final check sends three kinds of request: a default request, a valid page, and invalid limit or skip values. We inspect both response data and 422 status codes. The route function does not run for an invalid query because FastAPI rejects it at the boundary. The browser exercise on the lesson page tests an equivalent pure Python slice and a strict integer rule; it does not claim to run an ASGI server in the browser.
- 04:57
Framework parsing of incoming strings can differ from that exact-int exercise, so keep the layers separate. Query parameters belong in the URL, a model request body belongs in JSON, and the application slice decides which records are returned. When all three contracts are clear, a pagination endpoint is much easier to reason about and test. An offset is meaningful only relative to an order. If the list were drawn from a database without a stable sort, the same request might return different records across calls. This teaching list has fixed order; production pagination must specify one explicitly.
Understand the concept
FastAPI query parameters are values after the question mark in a URL, such as /items/?skip=1&limit=2. When these names are absent from the route path and function parameters have simple types, FastAPI reads them from the query string. An int annotation makes FastAPI parse and validate incoming text before calling the route.
Use Query(default=0, ge=0) for a nonnegative skip and Query(default=10, ge=1, le=50) for a bounded limit. The defaults make both optional. Invalid text, a negative skip, or an out-of-range limit yields HTTP 422. A required parameter would omit a default. Query values are strings on the wire; framework conversion happens before application slicing.
The local example uses FastAPI TestClient to send GET requests without deploying a server. It prints a paged response and a 422 code for an invalid limit. The browser task tests the slice and stricter exact-integer rules with pure Python because this page's console cannot host an ASGI server. These are separate layers: the pure function states application behavior and Query documents and validates HTTP input.
Simple skip/limit pagination is enough for an in-memory example. A database-backed endpoint needs a stable sort key and should apply pagination in the database query itself. Offset pagination may be inefficient or inconsistent when rows change; cursor pagination can be better for a large changing collection. The related FastAPI page covers JSON request bodies, a different source of input.
- query values come from the URL
- typed parameters parse before route execution
- Query constraints yield 422 for invalid input
- slice with skip and limit
See it step by step
Read the code, predict the output, then compare it with the result.
01. Read a paged URL
from urllib.parse import parse_qs, urlsplit
url = '/items/?skip=1&limit=2'
print(parse_qs(urlsplit(url).query)){'skip': ['1'], 'limit': ['2']}Query values travel as strings; FastAPI converts them using annotations.
02. Slice with the offset
items = ['pen', 'pad', 'clip', 'pencil']
skip, limit = 1, 2
print(items[skip:skip + limit])['pad', 'clip']Both slice start and stop use the offset.
03. Check invalid HTTP input
from fastapi import FastAPI, Query
from fastapi.testclient import TestClient
app = FastAPI()
@app.get('/items/')
def items(limit: int = Query(default=10, ge=1, le=50)):
return ['pen'][:limit]
print(TestClient(app).get('/items/?limit=0').status_code)422The HTTP layer rejects a zero limit before the route runs.
Run on your computer: this example needs a local Python environment.
Run the complete example locally
Run locally after python -m pip install 'fastapi[standard]'. TestClient exercises the HTTP boundary. The browser task practices only the slice and range contract.
from fastapi import FastAPI, Query
from fastapi.testclient import TestClient
app = FastAPI()
ITEMS = ['pen', 'pad', 'clip', 'pencil']
@app.get('/items/')
def list_items(skip: int = Query(default=0, ge=0),
limit: int = Query(default=10, ge=1, le=50)):
return ITEMS[skip:skip + limit]
client = TestClient(app)
print(client.get('/items/?skip=1&limit=2').json())
print(client.get('/items/?limit=0').status_code)['pad', 'clip']
422A closer look
Follow the reasoning, inspect each result, then try the suggested changes in the console below.
Separate URL query from a body
A GET URL carries skip and limit after ?. FastAPI sees these names are absent from the path template, and their simple annotated types make them query parameters. A Pydantic request body is a different input source. The query values are text in the incoming URL. The int annotations ask FastAPI to parse them before the route runs, so a malformed value receives a validation response instead of reaching slicing logic.
The query string arrives as text. An int annotation asks FastAPI to parse it. Send a nonnumeric value through TestClient to observe validation rather than passing raw text into a slice.
from urllib.parse import parse_qs, urlsplit
print(parse_qs(urlsplit('/items/?skip=1&limit=2').query)){'skip': ['1'], 'limit': ['2']}- Inspect a URL query string.
- Name the two query values.
Set defaults and bounds
A default makes a query parameter optional. Query(default=0, ge=0) allows zero or positive skips. Query(default=10, ge=1, le=50) limits page size. Keep the limit small enough for the service's response budget and document that maximum. A default is a convenience for callers, while the upper bound protects predictable resource use.
FastAPI documents these constraints and sends a validation error for bad input. The pure browser exercise uses exact-int checks to make its own rule explicit; framework conversion can differ for particular string inputs.
from fastapi import FastAPI, Query
from fastapi.testclient import TestClient
app = FastAPI()
@app.get('/items/')
def items(limit: int = Query(default=10, ge=1, le=50)):
return ['pen'][:limit]
print(TestClient(app).get('/items/?limit=0').status_code)422Run on your computer: this example needs a local Python environment.
- Run the local TestClient example.
- Compare a valid page with a zero limit.
Apply the page to an ordered collection
The slice items[skip:skip + limit] selects a page from an in-memory list. items[:limit] ignores skip and returns the first page every time. Always pair offset pagination with a stable ordering. If the backing collection changes or lacks a defined sort key, repeated requests can skip or duplicate records even when the slice arithmetic is correct.
A database endpoint needs stable ordering and should apply limits in its database query. For changing or very large sets, investigate cursor pagination rather than assuming offsets stay stable.
items = ['pen', 'pad', 'clip', 'pencil']
print(items[1:1 + 2])
print(items[9:9 + 2])['pad', 'clip']
[]- Compute slice bounds.
- Try an offset beyond the list length.
Try it in Python
Edit the example and run it. Python starts in your browser the first time you click Run.
Python console
Ready to runNeed input()? Add one value per line
Your output appears here.
Need a hint?
Validate both arguments, then return items[skip:skip + limit]. Query performs the HTTP-boundary checks in the local FastAPI route.
Complete the task and select Check task to verify your code.
Quick quiz
Three questions. You can change your answers and try again.
Typical mistakes
Everyone meets these errors. See what causes them and how to fix them.
Ignoring the offset
return items[:limit]return items[skip:skip + limit]What happens: Every page begins at the first item.
Slice start and stop must both reflect the offset.
Leaving page size unbounded
def list_items(limit: int = 10): ...limit: int = Query(default=10, ge=1, le=50)What happens: A caller can request an excessive response size.
A bounded HTTP parameter makes the contract explicit and documented.