Using Bevy's Changed<T> Filter for Efficient ECS Queries
Optimize Bevy ECS systems by using the Changed<T> filter to process only mutated components, reducing CPU overhead in sparse update scenarios.
18 Aug 2025, 16:27 UTC

The Problem with Full-Iteration Queries
In game development, it's common to write systems that process every entity with a specific component. A system updating a UI health bar might query every Health component each frame. This works fine for dozens of entities, but becomes inefficient when dealing with thousands of entities where only a handful actually change state each frame. The naive approach of polling every entity creates unnecessary CPU overhead.
How Bevy's Change Detection Works
Bevy implements change detection using a per-component change tick. Each component stores a tick value that increments whenever the component is mutated through Bevy's mutable accessors. When you use Changed<T> as a query filter, Bevy compares the component's current tick against the last tick when your system ran. Only entities with newer ticks are included in the query results. This allows systems to react only to actual changes rather than processing every entity every frame.
Practical Example: Reacting to Player Input
Consider a scenario where you need to process player input and update game state only when the player actually moves. Using Changed<Position> ensures your reaction system only runs for entities whose position changed, not for every entity with a position component.
use bevy::prelude::*;
#[derive(Component)]
struct Position { x: f32, y: f32 }
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Update, (move_player, handle_movement))
.run();
}
fn move_player(
mut query: Query<&mut Position, With>,
keys: Res>
) {
for mut pos in &mut query {
if keys.pressed(KeyCode::ArrowRight) {
pos.x += 1.0;
}
}
}
fn handle_movement(query: Query<&Position, Changed>) {
for pos in &query {
info!(r#"Player moved to ({}, {})"#, pos.x, pos.y);
}
}
Performance Considerations and Limitations
Change detection provides significant performance benefits when updates are sparse. However, it has important limitations: changes must occur through Bevy's mutable accessors, and the component must actually be mutated. Direct memory manipulation via unsafe code or modifications through external libraries bypass the tick system entirely. Additionally, if a system only reads a component (&T), the change tick won't advance even if the data changes through other means.
For verification, run your application with RUST_LOG=bevy=debug to observe the scheduler's execution order. You should see systems that mutate components execute before systems using Changed<T> filters within the same frame. Performance testing with thousands of entities where only a few change will show measurable differences in frame time between Query<&T> and Query<&T, Changed<T>> approaches.
When to Use Change Detection
Use Changed<T> when: the cost of your system's logic exceeds the query filtering overhead, you need to trigger one-time events based on mutations, or you're dealing with sparse updates like network events or animation triggers. Avoid it when every entity in your query is guaranteed to change each frame, as the filtering adds unnecessary overhead in those cases.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.