Demystifying Vapor filters
28 June 2026
Using .filter in Vapor
Building Vapor models has reasonable guardrails built in and enough similarity to Swift Structs and Classes where I feel like comprehension comes pretty quickly when learning Vapor.
One of the things that still leaves me staring at my screen with furrowed brow is querying those models, particularly when applying .filter and .with to include related models in a query.
So let’s talk about it!
A Model to Work With
I built and maintain a small location-based social network where users can “check-in” at places and post to the world. In the model I call a post a Status…because I don’t know why. I think as I was building the models the word “post” was getting confusing. But back to our project! For our sample model we’ll ignore the location aspect of a Status, assume that each status has a body, and some other properties, as well as a UserProfile acting as the Parent of that status.
Here is a simplified Status Model to work with.
final class Status: Model, Content, @unchecked Sendable {
static let schema = "status"
@ID(key: .id)
var id: UUID?
@Field(key: "timeStamp")
var timeStamp: Date
@Field(key: "body")
var body: String
@Field(key: "likes")
var likes: Int
// A Status belongs to a UserProfile
@Parent(key: "userProfileID")
var userProfile: UserProfile
init() { }
init(id: UUID? = nil, timeStamp: Date, body: String, likes: Int, userProfileID: UserProfile.IDValue) {
self.timeStamp = timeStamp
self.body = body
self.likes = likes
// Status is a child of UserProfile
self.$userProfile.id = userProfileID
}
}
For the purpose of this blog we’re ignoring the Status Migration. That’s the subject of a future post.
Also a quick note on the @Parent property. That is referred to as a “foreign key” because it references a value that lives on another table in our database. That was some naming that confused me in the past. So if you read something about a foreign key, or a “foreign key constraint”, that’s what it’s talking about.
Querying Statuses
Before we start filtering we’ll start with a basic Query. If you just wanted to return all Statuses on the server, the database query would look like this;
let allStatuses = try await Status.query(on: database).all()
Admittedly that didn’t help me understand a Query at first, so here’s a more detailed example showing a RouteCollection to create an endpoint you can query from your App. I’ve added comments in the code for further clarification.
struct StatusController: RouteCollection {
func boot(routes: any RoutesBuilder) throws {
/// The RouteCollection creates the URL path on your server to this specific collection of endpoints.
/// In practice you could hit this collection of endpoints with;
/// https://www.yourserverurl.com/api/status
let status = routes.grouped("api", "status")
/// To access the specific endpoint (aka function) used to get all Statuses on the server,
/// further define the route to your desired function.
/// https://www.yourserverurl.com/api/status/getAllStatuses
status.get("getAllStatuses", use: getAllStatuses)
}
/// Get all statuses
@Sendable
func getAllStatuses(req: Request) async throws -> [Status] {
try await Status.query(on: req.db).all()
}
}
Note in the code above the use: getAllStatuses points to the function you defined by that name. I’m using the same name for the route, but you don’t have to. Use what makes sense to you.
Add a Filter
That's a nice start but if your app has any success it's going to quickly become impractical to return all statuses for all users all the time. So let's do some filtering. For our first filter we want to find all posts that have more than 5 likes. Since my social network is so small, a status with 5 likes is basically going viral.
@Sendable
func getViralStatuses(req: Request) async throws -> [Status] {
let allViralStatuses = try await Status.query(on: req.db)
.filter(\.$likes > 5)
.all()
return allViralStatuses
}
You probably noticed the $ sign in that filter call. That indicates we are looking for the wrapped value of likes for the Status. If we had written .filter(\.likes > 5), well first off the compiler would complain. That’s because \.likes is the plain Int value. What Vapor needs (actually it’s Fluent but let’s not go there just yet) is the property wrapper to filter on because that @Field property wrapper contains the metadata needed to search/filter the database.
Another way to think of it is, imagine \.$someValue as a labeled envelope. It has the value inside, but on the outside there’s the address (the database column), return address, and a stamp. All the juicy metadata our database loves. While \.someValue is just the letter inside, we know what it says, but we don’t know where it came from.
As an aside, if you don’t like mathematical symbols you could also write that filter code like this;
@Sendable
let allViralStatuses = try await Status.query(on: req.db)
.filter(\.$likes, .greaterThan, 5)
.all()
Search Statuses
My friend Aaron loves pizza. The first thing he’d want to find on any social network are all posts talking about pizza. Let’s build that capability for Aaron.
@Sendable
func searchStatuses(req: Request) async throws -> [Status] {
guard let query = try? req.query.get(String.self, at: "query") else {
throw Abort(.notFound, reason: "No result found at query parameter.")
}
// "~~" means "Contains substring".
let matchingStatuses = try await Status.query(on: req.db)
.filter(\.$body ~~ query)
.all()
return matchingStatuses
}
While your own pizza cooks let’s talk about a couple of things in that method. The first guard where we define the query parameter takes a string from the URL request and uses that to filter through the bodies of all statuses on the server. The little ~~ symbols mean “Contains substring”. It’s kind of like “equal to” but instead of just one result, it’ll give you all of them. Then finally we have the .all() call at the end of the query. That’s where the query action actually gets executed on the server. You can stack multiple filter and with calls on top of each other as needed. But nothing actually gets processed until the .all(), .first() or .run() is called.
The endpoint that your App would use to search for the word pizza would look like this
http://www.yourserverurl.com/api/status/searchStatuses?query=pizza
Knowing what you know about routes you can probably see how I constructed this. Where searchStatuses is the end of the route followed by a “?” then the word “query” which is what I defined in the searchStatuses method. Then the “=” sign followed by whatever word you want to search for in the Status bodies on the server.
Searching deeper using .with
Remember the Status model defined a Status as having a @Parent property called userProfile. It would be reasonable to expect a our friend Aaron would want to know who was talking about pizza, not just the body text alone. To get that UserProfile information we’ll need to add a .with method to our query.
Before adding the .with method let’s look at the JSON response we’re already getting back from our query
{
"body": "I sure love this pizza",
"timeStamp": "2026-06-28T17:23:52Z",
"likes": 4,
"userProfile": {
"id": "4720B301-8ADA-4724-9C18-7AEEBA37C8CB"
},
"id": "06D5F528-863D-4625-9C33-480519349CCB"
}
You can see the userProfile information nested in that JSON response. Fluent always returns the foreign key directly, but that UUID isn’t much use to our friend Aaron. He wants to know the person’s name! Right now if we tried to return the string userProfile.userName, our server would crash because the relation wasn’t eager loaded. Our fix is to add the .with method to our query.
@Sendable
func filterWithUserProfileName(req: Request) async throws -> [MatchingStatusReturnDTO] {
guard let query = try? req.query.get(String.self, at: "query") else {
throw Abort(.notFound, reason: "No result found at query parameter.")
}
let matchingStatuses = try await Status.query(on: req.db)
.filter(\.$body ~~ query)
.with(\.$userProfile) // <- Notice the addition of the .with method here!
.all()
/// I've snuck in a Data Transfer Object. You can read more about that below.
let matchingStatusesDTO = matchingStatuses.map { status in
MatchingStatusReturnDTO(
body: status.body,
likes: status.likes,
userName: status.userProfile.userName
)
}
return matchingStatusesDTO
}
The MatchingStatusReturnDTO is a Data Transfer Object (also called a Domain Transfer Object) which organizes the returned information from the nested query. I won’t go deeply into DTO’s, but below is what that DTO struct looks like. You can read more about DTO’s in the documentation on this page. There’s also a subtlety in that code where the query parameter is case sensitive. Searching for Pizza will return different results than pizza. Obviously this is not ideal, you can use SQL Custom ILIKE to fix this, but it’s beyond the scope of this blog post. You can read more about ILIKE under SQL Custom.
Here's the DTO struct;
struct MatchingStatusReturnDTO: Content {
let body: String
let likes: Int
let userName: String
}
Making that change to our filterWithUserProfileName endpoint, the server will return the following JSON to us, which I think you’ll agree is a nice clean object to process in our app.
{
"body": "I love pizza after a race!",
"userName": "Oscar Piastri",
"likes": 71
},
{
"body": "At turn 4 there's pizza on the track! Double Yellow!",
"userName": "Lando Norris",
"likes": 122
}
You can read more about filtering Vapor queries in the official documentation.
Thank you for reading!
I hope this post helps you understand filtering and defining endpoints on a server using Vapor. In writing this blog I am not only trying to help others learn about Vapor, but I am also learning myself. If you see errors or major omissions, please don't hesitate to reach out to me using the links below.
Supporting Links
Connect with Dan: Bluesky Instagram
Download The Midst on the App StoreFind me on The Midst: dan
Vapor Documentation