Harnessing Bevy’s Change‑Detection Filters to Cut Unnecessary Work
Learn how to use Bevy’s Changed<T> and Added<T> filters to skip systems when components haven’t changed, with a concrete code example, limits, and common pitfalls.
20 Aug 2026, 05:14 UTC

Why Change‑Detection Matters in Bevy
In a data‑driven ECS like Bevy, systems run every frame. If a system touches many entities but only a handful actually changed, you waste CPU cycles. Bevy’s Changed and Added filters let you tell the scheduler, “Only run me when this component has been modified.” The result is a leaner loop and more predictable frame times.
How the Filters Work Under the Hood
Each component type in Bevy has an internal ChangeDetection flag. When you mutate a component via a mutable reference (e.g., &mut T), the flag is set. The next frame, a system that includes Changed in its query will only receive entities whose flag is set. After the system runs, the flag is cleared automatically. Added behaves similarly but is set only on the frame immediately after an entity receives the component.
Worked Example
Below is a minimal Bevy app that demonstrates how to use Changed and Added. Run it on a machine with the standard Bevy 0.13 toolchain. The example uses the default logger to show when systems execute.
use bevy::prelude::*;
#[derive(Component, Debug)]
struct Position(Vec3);
#[derive(Component, Debug)]
struct Velocity(Vec3);
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_startup_system(setup)
.add_system(move_entities) // runs every frame
.add_system(process_moved_entities) // runs only when Position changed
.add_system(process_new_entities) // runs only when Velocity added
.run();
}
fn setup(mut commands: Commands) {
// Create an entity that will move every frame
commands.spawn((Position::default(), Velocity(Vec3::new(1.0, 0.0, 0.0))));
// Create a static entity that will never move
commands.spawn((Position(Vec3::new(10.0, 0.0, 0.0)),));
}
fn move_entities(mut query: Query<(&mut Position, &Velocity)>) {
for (mut pos, vel) in query.iter_mut() {
pos.0 += vel.0;
}
}
fn process_moved_entities(mut query: Query<(&Position, Changed<Position>)>) {
for (pos, _) in query.iter() {
// Only called for entities whose Position changed this frame
println!("Position changed to {:?}", pos);
}
}
fn process_new_entities(mut query: Query<&Velocity, Added<Velocity>>) {
for vel in query.iter() {
// Only called for entities that just received Velocity
println!("Velocity added: {:?}", vel);
}
}
Key points in the code:
- The
move_entitiessystem mutatesPositionfor every entity that has bothPositionandVelocity. - The
process_moved_entitiessystem includesChanged<Position>in its query. It will be scheduled only for entities whosePositionchanged in the current frame. - The
process_new_entitiessystem usesAdded<Velocity>to react only on the frame aVelocitycomponent is first attached.
Verifying the Result
When you run the program, you should see output similar to:
Added Velocity: Vec3(1.0, 0.0, 0.0)
Position changed to Vec3(1.0, 0.0, 0.0)
Position changed to Vec3(2.0, 0.0, 0.0)
…
The process_new_entities system prints only once, right after the entity is spawned. The process_moved_entities prints each frame for the moving entity only; the static entity never triggers it. You can also use Bevy’s built‑in profiler (Ctrl+F5) to see that process_new_entities runs only on the first frame.
Limits and Common Mistakes
- Only tracks component value changes. Adding or removing a component does not set the
Changedflag for that component type; you must useAddedorRemovedfilters for those cases. - Mutable borrows lock parallelism. If two systems both request mutable access to the same component type, Bevy will serialize them. Using
Changeddoes not bypass this rule. - Frequent small updates can negate benefits. If a component changes every frame,
Changedoffers no savings. It’s most effective for infrequent updates. - Custom components need
ChangeDetectionsupport. By default, all components implement the marker trait, but if you disable change detection for a type (via#[derive(Component, Reflect, ChangeDetection)]or the#[change_detection(false)]attribute), the filter will never fire. - Resources are not automatically tracked. If you want to skip a system when a resource hasn’t changed, you need to wrap the resource in a component or use
Resourcemanually. - Over‑use of
Addedcan mislead.Addedfires only on the frame immediately after the component is added. If you add a component, then remove and re‑add it in the next frame, the system will run again, potentially causing duplicated logic.
When to Use Change‑Detection Filters
- Event‑like systems. Systems that should react only when something changes (e.g., updating a UI element when a player’s health changes).
- Physics or AI updates. If an AI system should only recompute a path when the target moves,
Changedcan prevent unnecessary pathfinding. - Debug or logging systems. Log only when a component changes to avoid noisy output.
Conclusion
Bevy’s Changed and Added filters give you a clean, data‑driven way to prune work. By positioning these filters in system signatures, you let the scheduler do the heavy lifting: only run systems when the data they care about actually changes. Pair this with careful component design—avoid mutating components every frame unless necessary—and you’ll keep your ECS loop lean and predictable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.