Master Pagination in FastAPI for Efficient Data Loading
As your application grows, serving all data at once can lead to performance issues and a poor user experience. This tutorial guides you through implementing backend-driven pagination in your FastAPI application, allowing users to load data in manageable chunks. You’ll learn to use query parameters, implement database pagination with SQL Alchemy, and integrate a ‘load more’ feature on the frontend.
Prerequisites
- A working FastAPI application (following previous tutorials in this series is recommended).
- Basic understanding of Python, FastAPI, SQL Alchemy, and HTML/JavaScript.
- The provided sample data generation script and images.
Step 1: Populate Your Database with Sample Data
To effectively test pagination, you need a substantial amount of data. The tutorial provides a populate_db.py script for this purpose.
- Locate the script: Find
populate_db.pyin the tutorial files. - Review the script (Optional): This script creates sample users, assigns profile pictures, and generates 44 sample posts with realistic dates. It interacts with your API to create these records, ensuring they are processed correctly.
- Warning: Running this script will clear your existing database and replace it with the sample data. Ensure this is intended before proceeding.
- You will also need the images from the
populate_imagesdirectory for profile pictures.
- Run the script: Stop your FastAPI server if it’s running. Then, execute the script from your terminal:
uvicorn run python populate_db.py(If you are not using Uvicorn, use your standard Python interpreter:
python populate_db.py) - Verify data: After the script confirms the creation of users and posts, restart your FastAPI server (e.g.,
uvicorn run fastapi dev main.py) and check your application’s homepage. You should now see a significantly larger number of posts loaded at once, demonstrating the need for pagination.
Step 2: Define the Paginated Response Schema
The API needs a new contract with the frontend to communicate paginated data. This includes the data itself, along with metadata about the current page and total available data.
- Open schemas.py: Navigate to your
schemas.pyfile.- Tip: If your imports auto-sort, don’t worry if the order changes after saving.
- Add the
PaginatedPostResponseclass: Append the following class definition to the end of the file:from typing import List from pydantic import BaseModel class PostResponse(BaseModel): # ... (existing fields for PostResponse) pass class PaginatedPostResponse(BaseModel): posts: List[PostResponse] total: int skip: int limit: int has_more: bool
Explanation of Fields:
- posts: A list containing the actual post data for the current page.
- total: The total number of posts available in the database.
- skip: The number of posts skipped to reach the current batch (offset).
- limit: The maximum number of posts requested for the current batch.
- has_more: A boolean indicating whether there are additional posts beyond the current batch. This simplifies frontend logic for displaying a “load more” button.
Step 3: Implement Pagination in the Get Posts Endpoint
Modify the existing API endpoint to accept pagination parameters and return the paginated response.
- Open post router: Navigate to
routers/post.py.- Add
Queryfromfastapiandfuncfromsqlalchemyto your imports. - Import the newly created
PaginatedPostResponsefrom your schemas.
- Add
- Update the
get_postsendpoint function signature:- Change the
response_modeltoPaginatedPostResponse. - Add
skip: int = Query(ge=0, default=0)andlimit: int = Query(ge=1, le=100, default=10)as parameters.
Explanation of Query Parameters:
- skip (
ge=0,default=0): Ensures the offset is non-negative. Defaults to 0 if not provided. - limit (
ge=1, le=100,default=10): Ensures at least one post is requested and caps the maximum at 100 to prevent resource exhaustion. Defaults to 10. - Tip: Using
skipandlimitoffers more flexibility thanpageandper_page, aligning with common REST API practices.
- Change the
- Add a total count query: Before fetching posts, add a query to count all posts:
total_posts = db.query(func.count(models.Post.id)).scalar() if total_posts is None: total_posts = 0 - Modify the post fetching query:
- Add
.offset(skip)and.limit(limit)to your existing query. - Crucially, ensure an
.order_by(models.Post.date_posted.desc())clause is present. This guarantees consistent results across requests. Without ordering, the database might return different sets of posts for the sameskipandlimitvalues.
- Add
- Calculate
has_more: Determine if more posts exist after the current batch:has_more = (skip + len(posts)) < total_posts - Construct and return the
PaginatedPostResponse: Instead of returning just the list of posts, create an instance ofPaginatedPostResponse. You’ll need to manually serialize the posts usingPostResponse.from_orm()(or similar Pydantic validation) to ensure nested data like the author is included correctly.return PaginatedPostResponse( posts=[PostResponse.from_orm(post) for post in posts], total=total_posts, skip=skip, limit=limit, has_more=has_more, )
Step 4: Test the Paginated API Endpoint
Use the FastAPI interactive documentation to verify your implementation.
- Access the Docs: Ensure your server is running and navigate to your API’s documentation page (usually
/docs). - Find the GET /posts endpoint: Expand the endpoint details.
- Use “Try it out”:
- Execute with default parameters (skip=0, limit=10). You should receive the first 10 posts, with
total: 44,skip: 0,limit: 10, andhas_more: true. - Experiment with different
skipandlimitvalues (e.g., skip=10, limit=10). - Test the edge case where you skip enough posts to exhaust the total (e.g., skip=40, limit=10). You should get the remaining posts, and
has_moreshould befalse. - Test validation by entering invalid values (e.g., limit=200). You should receive a validation error (HTTP 422).
- Execute with default parameters (skip=0, limit=10). You should receive the first 10 posts, with
Step 5: Implement Pagination on the Frontend (Homepage)
Update the main homepage route and template to fetch and display only the initial batch of posts, preparing for JavaScript-driven loading.
- Centralize
posts_per_page:- Create or update
config.py. - Add a setting for the number of posts per page:
POSTS_PER_PAGE = 10.
- Create or update
- Update
main.py(Home Route):- Import
Queryfromfastapiandfuncfromsqlalchemy. - Import
settingsfromconfig. - Locate the
homeroute function.- Replace the existing post fetching logic with pagination logic similar to the API endpoint, but without the
skipparameter (as it’s always the first page). Usesettings.POSTS_PER_PAGEfor the limit. - Calculate
has_morebased on the number of posts returned and the total count. - Pass the
has_morevariable to the template context.
- Replace the existing post fetching logic with pagination logic similar to the API endpoint, but without the
- Import
- Add JavaScript Utilities:
- Open
static/utils.js.- Add an
escapeHtmlfunction to prevent XSS attacks by properly sanitizing user-generated content before injecting it via JavaScript. - Add a
formatDatefunction to convert ISO date strings from the API into a more readable format (e.g., Month Day, Year) matching the server-rendered dates.
- Add an
- Open
- Update
templates/home.html:- Wrap the existing post loop in a
<div id="post-container"></div>. This div will be the target for appending new posts loaded via JavaScript. - Below the
post-containerdiv, add a button that will be conditionally rendered ifhas_moreis true:{% if has_more %} <button id="load-more-btn">Load More Posts</button> {% endif %} - Add a
<script>block at the end of the file.- Initialize state: Set initial values for
currentOffset(equal toPOSTS_PER_PAGEsince the first batch is server-rendered) andhasMoreusing Jinja2 templating to inject server-rendered values. createPostHtml(post)function: This function generates the HTML for a single post, mirroring the structure in the template. It usesescapeHtmlandformatDatefor security and display.loadMorePosts()function:- Fetches data from the paginated API endpoint (
/api/posts) using thecurrentOffsetandPOSTS_PER_PAGE. - Appends the newly fetched posts (converted to HTML using
createPostHtml) to the#post-container. - Updates
currentOffset. - Hides the “Load More” button if no more posts are available.
- Handles errors by updating the button text to prompt retry.
- Fetches data from the paginated API endpoint (
- Add an event listener to the
#load-more-btnto callloadMorePosts()when clicked.
- Initialize state: Set initial values for
- Wrap the existing post loop in a
Step 6: Test Homepage Pagination
- Reload your homepage. You should see the first 10 posts.
- Click “Load More Posts”. New posts should dynamically load below the existing ones.
- Continue clicking until all posts are loaded. The “Load More Posts” button should disappear.
Step 7: Apply Pagination to User Post Pages
Extend the pagination logic to individual user profile pages.
- Update User Router (
routers/user.py):- Add
Queryto FastAPI imports andPaginatedPostResponsefrom schemas. - Locate the
get_user_postsendpoint.- Modify its signature to accept
skipandlimitquery parameters, similar to the mainget_postsendpoint. - Update the
response_modeltoPaginatedPostResponse. - In the database queries (both count and fetch), add a filter
.filter(models.Post.user_id == user_id)to retrieve only posts belonging to the specified user. - Implement the offset, limit, calculate
has_more, and return thePaginatedPostResponseas done in Step 3.
- Modify its signature to accept
- Add
- Update Main Route (
main.py):- Find the
user_postsroute function.- Add
Queryandsettingsimports. - Implement pagination logic using
skip,settings.POSTS_PER_PAGE, and calculatehas_more, similar to the homepage route. - Filter the post count and fetch queries by
user_id. - Pass
has_moreto theuser_posts.htmltemplate.
- Add
- Find the
- Update User Post Template (
templates/user_posts.html):- This template is very similar to
home.html. Wrap the post loop in a<div id="post-container"></div>. - Add the conditional “Load More Posts” button.
- Add a
<script>block at the end.- Initialize pagination state (
currentOffset,hasMore). - The
createPostHtmlfunction will be the same. - The
loadMorePostsfunction needs to be adjusted to fetch from the user-specific API endpoint (e.g.,/api/users/{user_id}/posts) instead of the general/api/postsendpoint.
- Initialize pagination state (
- This template is very similar to
Step 8: Test User Post Pagination
Navigate to a user’s profile page. You should see their posts paginated, with a “Load More Posts” button if they have more than the limit.
Further Considerations
For more complex applications, consider using a dedicated library like FastAPI Pagination. While manual implementation is excellent for learning, libraries can automate much of the boilerplate code, offering various strategies and consistent response formats. Evaluate if the library’s benefits outweigh its potential complexity for your production environment.
Source: Python FastAPI Tutorial (Part 13): Pagination – Loading More Data with Query Parameters (YouTube)