Scope Bevy ECS Queries With Filters and Change Detection
Use With, Without, Changed, and Added to target only the entities and moments that matter in Bevy systems.
09 Aug 2025, 13:41 UTC

The problem: systems that see too much and run too often
Your Bevy system moves every entity with a Transform and Velocity. That works until a decorative rock picks up Velocity and starts drifting across the level, or your user interface rebuilds every frame even when nothing changed. Bevy organizes game state as entities with components, and systems execute over Queries that match specific component combinations. Without filters, a query is too broad; without change detection, it runs more often than necessary.
The useful takeaway is to scope queries with With and Without, trigger logic only on state transitions with Changed and Added, and defer structural mutations such as spawn and insert with Commands.
Narrow a query with With and Without
With<T> includes only entities carrying component T. Without<T> excludes entities carrying T. You can combine them in a query filter tuple so a system touches only the intended objects.
use bevy::prelude::*;
#[derive(Component)]
struct Player;
#[derive(Component)]
struct Velocity(Vec2);
#[derive(Component)]
struct Static;
fn apply_player_velocity(
mut query: Query<(&mut Transform, &Velocity), (With<Player>, Without<Static>)>,
) {
for (mut transform, velocity) in query.iter_mut() {
transform.translation.x += velocity.0.x;
transform.translation.y += velocity.0.y;
}
}
This system compiles only for entities that have both Player and Velocity and do not have Static. Scenery is left alone even if it somehow carries a Velocity component.
Run logic only when components change
Changed<T> matches entities where component T was modified since the last relevant system run. Added<T> matches entities where T was inserted since the last relevant run. These filters let you avoid per-frame work for data that is usually stable.
fn log_velocity_changes(
query: Query<&Velocity, Changed<Velocity>>,
) {
for velocity in query.iter() {
// Runs only for entities whose Velocity changed
// since this system last observed it.
}
}
fn welcome_new_players(
mut commands: Commands,
query: Query<Entity, Added<Player>>,
) {
for entity in query.iter() {
commands.entity(entity).insert(WelcomeMessage);
}
}
#[derive(Component)]
struct WelcomeMessage;
Changed and Added are relative to the last run of systems depending on that component, not necessarily the last rendered frame. If you need frame-aligned behavior, combine change detection with an explicit timer or frame resource.
Defer world mutations with Commands
Bevy borrows component storage while a system iterates a query. Calling spawn, despawn, or insert directly inside that iteration can cause borrow conflicts. Commands records mutations and applies them after the current system batch finishes.
fn spawn_effects(
mut commands: Commands,
query: Query<Entity, Added<Player>>,
) {
for entity in query.iter() {
commands.spawn_empty();
}
}
The spawned entities are not visible to queries in the same system. Check for them in a system scheduled later, or use Added<T> on a marker component you insert during spawn.
Trade-offs and how to verify
| Filter | Effect |
|---|---|
With<T> | Include only entities with component T |
Without<T> | Exclude entities with component T |
Changed<T> | Match entities where T was modified since last relevant run |
Added<T> | Match entities where T was inserted since last relevant run |
Filters are compile-time: you cannot decide at runtime to exclude an entity without running a conditional inside the loop. Change detection adds a small tracking cost, so use it when the work you skip is more expensive than the bookkeeping. Bevy’s query API changes between minor releases, so confirm method names against the Bevy version in your Cargo.lock.
Run cargo check in your project root; no elevated permissions are required. If two systems borrow &mut Transform in parallel, Bevy may report a borrow conflict at compile time or runtime depending on scheduler configuration. A practical check is to add a temporary log inside the filtered loop and confirm it fires only for expected entities.
Closing
Start with With and Without to stop systems from touching the wrong entities. Add Changed or Added when you only need to react to transitions. Use Commands whenever a system must spawn, despawn, or insert during iteration. These patterns keep Bevy systems focused, performant, and free of borrow conflicts.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.