Skip to main content
This guide demonstrates how to combine data from two tables using SQLAlchemy joins within a FastAPI posts router. We’ll build the query incrementally: start from a basic posts query, add a join to the votes table, aggregate (count) votes per post, and then apply filters, limit, and offset. Finally, we’ll cover response-model mismatches and two strategies to resolve them. Why this matters
  • Efficiently fetch posts with vote counts in a single query.
  • Avoid N+1 query problems by leveraging SQL joins and aggregation.
  • Ensure FastAPI/Pydantic response models match the returned data shape.
Table of contents
  • Basic posts query
  • Adding a JOIN
  • Counting votes and grouping
  • Complete query with filters, limit, and offset
  • Response model mismatches and solutions
  • Final router implementation
  • Alternative: flattening results
  • Summary and references

Basic posts query (fetch posts only)

This is the starting get_posts implementation that retrieves posts only:
Note: the core of the query is db.query(models.Post) — this returns mapped Post model instances (one per row). If you remove .all() you get a SQLAlchemy Query object; inspect it with str(query) or query.statement to view the generated SQL for debugging.

Adding a JOIN

To include vote information, add a join(...) to the query. This demonstrates joining the votes table on the foreign key:
By default SQLAlchemy produces an inner join. Use isouter=True to create a LEFT OUTER JOIN so posts with zero votes are included.
If you want to include posts without any votes (i.e., zero votes), use a left outer join:

Counting votes and grouping

To compute vote counts per post, import SQLAlchemy’s func and apply func.count(...) along with .group_by(...). Use .label(...) to name the aggregated column:
The generated SQL is equivalent to: SELECT posts.*, count(votes.post_id) AS votes
FROM posts LEFT OUTER JOIN votes ON votes.post_id = posts.id
GROUP BY posts.id;
Naming the label (we used “votes”) makes it easy to read the results and include the count in the API response.

Complete query with filters, limit, and offset

Re-apply filtering, pagination, and execute the query:
This returns a list where each row contains two elements (commonly as a tuple): (PostInstance, votes_count).

Pydantic response model mismatch and solution

When your endpoint’s response_model is List[schemas.Post] but you return (PostInstance, votes_count) rows, FastAPI/Pydantic will raise validation errors because the returned structure doesn’t match the expected schema shape. Two approaches to fix this:
  1. Create a response schema that matches the returned tuple/nested shape.
  2. Flatten/transform each row into a dict matching an existing schema.
Below is an example response schema approach. Example schemas to match the returned structure
Important: If your query serialization nests the Post under a capitalized Post key, your Pydantic model must match that key exactly. Otherwise set a different returned shape or adjust your schema accordingly.
If your response model does not match the exact shape (keys, nesting, capitalization) of the returned data, FastAPI/Pydantic will raise validation errors. Update the returned data shape or the response model to match.

Final router implementation (response model matches joined results)

Set the endpoint’s response_model to List[schemas.PostOut] and return the query results directly:
Returned JSON shape (example):

Alternative: flattening results into a single dict per post

If you prefer a top-level post object that includes votes (no nested Post key), transform the query rows before returning:
This approach lets you keep an existing schemas.PostWithVotes (or similar) with votes as a top-level field.

Quick reference table — strategies and use cases

Best practices and tips

  • Always inspect generated SQL when debugging: str(query) or query.statement.
  • Use isouter=True for left outer joins to include items without matches.
  • Set orm_mode = True on Pydantic models that accept ORM objects.
  • Ensure your response_model exactly matches the keys, nesting, and capitalization of the returned data.
  • If returning ORM instances directly, prefer response models designed for ORM dataclasses; when returning dicts, use plain Pydantic models.
Summary
  • Build the SQLAlchemy query step-by-step: select entities, join (use isouter=True for LEFT JOIN), aggregate with func, group_by, then apply filters, limit, and offset.
  • When returning results from joined queries, choose between updating/creating response models or flattening the result to match an existing schema.
  • Always ensure Pydantic models match the exact structure returned by your endpoints to avoid validation errors.

Watch Video