diff --git a/godot-core/src/meta/mod.rs b/godot-core/src/meta/mod.rs
index e516df64f..9a6e3880d 100644
--- a/godot-core/src/meta/mod.rs
+++ b/godot-core/src/meta/mod.rs
@@ -54,10 +54,12 @@ mod signature;
 mod traits;
 
 pub mod error;
+pub mod property_update;
 
 pub use args::*;
 pub use class_name::ClassName;
 pub use godot_convert::{FromGodot, GodotConvert, ToGodot};
+pub use property_update::PropertyUpdate;
 pub use traits::{ArrayElement, GodotType, PackedArrayElement};
 
 pub(crate) use array_type_info::ArrayTypeInfo;
diff --git a/godot-core/src/meta/property_update.rs b/godot-core/src/meta/property_update.rs
new file mode 100644
index 000000000..b037f1abd
--- /dev/null
+++ b/godot-core/src/meta/property_update.rs
@@ -0,0 +1,14 @@
+pub struct PropertyUpdate<'a, C, T> {
+    pub new_value: T,
+    pub field_name: &'a str, // might also be &'a StringName, depending on what's available
+    pub get_field_mut: fn(&mut C) -> &mut T,
+}
+
+impl<C, T> PropertyUpdate<'_, C, T> {
+    pub fn set(self, obj: &mut C) {
+        *(self.get_field_mut)(obj) = self.new_value;
+    }
+    pub fn set_custom(self, obj: &mut C, value: T) {
+        *(self.get_field_mut)(obj) = value;
+    }
+}
diff --git a/godot-macros/src/class/data_models/field_var.rs b/godot-macros/src/class/data_models/field_var.rs
index 4b3c25be0..4da127855 100644
--- a/godot-macros/src/class/data_models/field_var.rs
+++ b/godot-macros/src/class/data_models/field_var.rs
@@ -13,7 +13,7 @@ use crate::class::{
     FuncDefinition,
 };
 use crate::util::make_funcs_collection_constant;
-use crate::util::KvParser;
+use crate::util::{bail, KvParser};
 use crate::{util, ParseResult};
 
 /// Store info from `#[var]` attribute.
@@ -21,11 +21,60 @@ use crate::{util, ParseResult};
 pub struct FieldVar {
     pub getter: GetterSetter,
     pub setter: GetterSetter,
+    pub notify: Option<Ident>,
     pub hint: FieldHint,
     pub usage_flags: UsageFlags,
     pub span: Span,
 }
 
+fn parse_notify(parser: &mut KvParser, key: &str) -> ParseResult<Option<Ident>> {
+    match parser.handle_any(key) {
+        // No `notify` argument
+        None => Ok(None),
+        Some(value) => match value {
+            // `notify` without value is an error
+            None => {
+                bail!(
+                    parser.span(),
+                    "The correct syntax is 'notify = callback_fn'"
+                )
+            }
+            // `notify = expr`
+            Some(value) => match value.ident() {
+                Ok(ident) => Ok(Some(ident)),
+                Err(_) => bail!(
+                    parser.span(),
+                    "The correct syntax is 'notify = callback_fn'"
+                ),
+            },
+        },
+    }
+}
+
+fn parse_setter_ex(parser: &mut KvParser, key: &str) -> ParseResult<Option<Ident>> {
+    match parser.handle_any(key) {
+        // No `notify` argument
+        None => Ok(None),
+        Some(value) => match value {
+            // `notify` without value is an error
+            None => {
+                bail!(
+                    parser.span(),
+                    "The correct syntax is 'setter_ex = callback_fn'"
+                )
+            }
+            // `notify = expr`
+            Some(value) => match value.ident() {
+                Ok(ident) => Ok(Some(ident)),
+                Err(_) => bail!(
+                    parser.span(),
+                    "The correct syntax is 'setter_ex = callback_fn'"
+                ),
+            },
+        },
+    }
+}
+
 impl FieldVar {
     /// Parse a `#[var]` attribute to a `FieldVar` struct.
     ///
@@ -39,12 +88,32 @@ impl FieldVar {
         let span = parser.span();
         let mut getter = GetterSetter::parse(parser, "get")?;
         let mut setter = GetterSetter::parse(parser, "set")?;
+        let notify = parse_notify(parser, "notify")?;
+        let setter_ex = parse_setter_ex(parser, "set_ex")?;
 
-        if getter.is_omitted() && setter.is_omitted() {
+        if getter.is_omitted() && setter.is_omitted() && setter_ex.is_none() {
             getter = GetterSetter::Generated;
             setter = GetterSetter::Generated;
         }
 
+        if notify.is_some() && !setter.is_generated() {
+            return bail!(
+                parser.span(),
+                "When using 'notify', the property must also use an autogenerated 'set'"
+            );
+        }
+
+        if setter_ex.is_some() && !setter.is_omitted() {
+            return bail!(
+                parser.span(),
+                "You may not use 'set' and 'set_ex' at the same time, remove one"
+            );
+        }
+
+        if let Some(ident) = setter_ex {
+            setter = GetterSetter::Ex(ident);
+        }
+
         let hint = parser.handle_ident("hint")?;
 
         let hint = if let Some(hint) = hint {
@@ -72,6 +141,7 @@ impl FieldVar {
         Ok(FieldVar {
             getter,
             setter,
+            notify,
             hint,
             usage_flags,
             span,
@@ -87,6 +157,7 @@ impl Default for FieldVar {
             hint: Default::default(),
             usage_flags: Default::default(),
             span: Span::call_site(),
+            notify: Default::default(),
         }
     }
 }
@@ -102,6 +173,9 @@ pub enum GetterSetter {
 
     /// Getter/setter is handwritten by the user, and here is its identifier.
     Custom(Ident),
+
+    /// only applicable to setter. A generic setter that takes 'PropertyUpdate<C, T>' is handwritten by the user
+    Ex(Ident),
 }
 
 impl GetterSetter {
@@ -128,36 +202,48 @@ impl GetterSetter {
         &self,
         class_name: &Ident,
         kind: GetSet,
+        notify: Option<Ident>,
         field: &Field,
     ) -> Option<GetterSetterImpl> {
         match self {
             GetterSetter::Omitted => None,
             GetterSetter::Generated => Some(GetterSetterImpl::from_generated_impl(
-                class_name, kind, field,
+                class_name, kind, notify, field,
             )),
             GetterSetter::Custom(function_name) => {
                 Some(GetterSetterImpl::from_custom_impl(function_name))
             }
+            GetterSetter::Ex(_function_name) => {
+                assert!(matches!(kind, GetSet::SetEx(_)));
+                Some(GetterSetterImpl::from_generated_impl(
+                    class_name, kind, notify, field,
+                ))
+            }
         }
     }
 
     pub fn is_omitted(&self) -> bool {
         matches!(self, GetterSetter::Omitted)
     }
+
+    pub fn is_generated(&self) -> bool {
+        matches!(self, GetterSetter::Generated)
+    }
 }
 
 /// Used to determine whether a [`GetterSetter`] is supposed to be a getter or setter.
-#[derive(Copy, Clone, Eq, PartialEq, Debug)]
+#[derive(Clone, Eq, PartialEq, Debug)]
 pub enum GetSet {
     Get,
     Set,
+    SetEx(Ident),
 }
 
 impl GetSet {
     pub fn prefix(&self) -> &'static str {
         match self {
             GetSet::Get => "get_",
-            GetSet::Set => "set_",
+            GetSet::Set | GetSet::SetEx(_) => "set_",
         }
     }
 }
@@ -171,7 +257,12 @@ pub struct GetterSetterImpl {
 }
 
 impl GetterSetterImpl {
-    fn from_generated_impl(class_name: &Ident, kind: GetSet, field: &Field) -> Self {
+    fn from_generated_impl(
+        class_name: &Ident,
+        kind: GetSet,
+        notify: Option<Ident>,
+        field: &Field,
+    ) -> Self {
         let Field {
             name: field_name,
             ty: field_type,
@@ -196,9 +287,54 @@ impl GetterSetterImpl {
                 signature = quote! {
                     fn #function_name(&mut self, #field_name: <#field_type as ::godot::meta::GodotConvert>::Via)
                 };
-                function_body = quote! {
+
+                let function_body_set = quote! {
                     <#field_type as ::godot::register::property::Var>::set_property(&mut self.#field_name, #field_name);
                 };
+
+                function_body = match notify {
+                    Some(ident) => {
+                        quote! {
+                            let prev_value = self.#field_name;
+                            #function_body_set
+                            if prev_value != self.#field_name {
+                                self.#ident();
+                            }
+                        }
+                    }
+                    None => function_body_set,
+                };
+            }
+            GetSet::SetEx(callback_fn_ident) => {
+                signature = quote! {
+                    fn #function_name(&mut self, #field_name: <#field_type as ::godot::meta::GodotConvert>::Via)
+                };
+
+                let field_name_string_constant = field_name.to_string();
+
+                let function_body_set = quote! {
+
+                    let new_value = ::godot::meta::FromGodot::from_godot(#field_name);
+                    let property_update = ::godot::meta::PropertyUpdate {
+                        new_value: new_value,
+                        field_name: #field_name_string_constant,
+                        get_field_mut: |c: &mut #class_name|&mut c.#field_name
+                    };
+                    self.#callback_fn_ident(property_update);
+                };
+
+                function_body = match notify {
+                    Some(ident) => {
+                        quote! {
+                            let prev_value = self.#field_name;
+                            #function_body_set
+                            if prev_value != self.#field_name {
+                                self.#ident();
+                            }
+                        }
+                    }
+                    None => function_body_set,
+                };
             }
         }
 
diff --git a/godot-macros/src/class/data_models/property.rs b/godot-macros/src/class/data_models/property.rs
index 719c61a9e..4c5405e31 100644
--- a/godot-macros/src/class/data_models/property.rs
+++ b/godot-macros/src/class/data_models/property.rs
@@ -7,7 +7,7 @@
 
 //! Parses the `#[var]` and `#[export]` attributes on fields.
 
-use crate::class::{Field, FieldVar, Fields, GetSet, GetterSetterImpl, UsageFlags};
+use crate::class::{Field, FieldVar, Fields, GetSet, GetterSetter, GetterSetterImpl, UsageFlags};
 use crate::util::{format_funcs_collection_constant, format_funcs_collection_struct};
 use proc_macro2::{Ident, TokenStream};
 use quote::quote;
@@ -70,6 +70,7 @@ pub fn make_property_impl(class_name: &Ident, fields: &Fields) -> TokenStream {
         let FieldVar {
             getter,
             setter,
+            notify,
             hint,
             mut usage_flags,
             ..
@@ -139,14 +140,18 @@ pub fn make_property_impl(class_name: &Ident, fields: &Fields) -> TokenStream {
         // Note: {getter,setter}_tokens can be either a path `Class_Functions::constant_name` or an empty string `""`.
 
         let getter_tokens = make_getter_setter(
-            getter.to_impl(class_name, GetSet::Get, field),
+            getter.to_impl(class_name, GetSet::Get, None, field),
             &mut getter_setter_impls,
             &mut func_name_consts,
             &mut export_tokens,
             class_name,
         );
+        let setter_kind = match &setter {
+            GetterSetter::Ex(ident) => GetSet::SetEx(ident.clone()),
+            _ => GetSet::Set,
+        };
         let setter_tokens = make_getter_setter(
-            setter.to_impl(class_name, GetSet::Set, field),
+            setter.to_impl(class_name, setter_kind, notify, field),
             &mut getter_setter_impls,
             &mut func_name_consts,
             &mut export_tokens,
diff --git a/itest/rust/src/object_tests/property_test.rs b/itest/rust/src/object_tests/property_test.rs
index 1b878607b..85a8ab73a 100644
--- a/itest/rust/src/object_tests/property_test.rs
+++ b/itest/rust/src/object_tests/property_test.rs
@@ -537,3 +537,82 @@ fn test_var_with_renamed_funcs() {
 
     obj.free();
 }
+
+// ---------------------------------------------------------------
+
+#[derive(GodotClass)]
+#[class(base=Node, init)]
+struct NotifyTest {
+    #[var(notify = on_change)]
+    a: i32,
+    #[var(notify = on_change)]
+    b: i32,
+
+    pub call_count: u32,
+}
+
+impl NotifyTest {
+    fn on_change(&mut self) {
+        self.call_count += 1;
+    }
+}
+
+#[itest]
+fn test_var_notify() {
+    let mut class = NotifyTest::new_alloc();
+
+    assert_eq!(class.bind().call_count, 0);
+
+    class.call("set_a", &[3.to_variant()]);
+    assert_eq!(class.bind().a, 3);
+    assert_eq!(class.bind().call_count, 1);
+
+    class.call("set_b", &[5.to_variant()]);
+    assert_eq!(class.bind().b, 5);
+    assert_eq!(class.bind().call_count, 2);
+
+    class.free();
+}
+
+// ---------------------------------------------------------------
+
+#[derive(GodotClass)]
+#[class(base=Node, init)]
+struct SetExTest {
+    #[var(set_ex = custom_set)]
+    a: i32,
+    #[var(set_ex = custom_set)]
+    b: i32,
+
+    pub call_count: u32,
+}
+
+impl SetExTest {
+    fn custom_set<T>(&mut self, update: godot::meta::property_update::PropertyUpdate<Self, T>) {
+        // pre-set checks
+
+        update.set(self);
+
+        // post-set actions
+        self.call_count += 1;
+    }
+}
+
+#[itest]
+fn test_var_set_ex() {
+    let mut class = NotifyTest::new_alloc();
+
+    assert_eq!(class.bind().call_count, 0);
+
+    class.call("set_a", &[3.to_variant()]);
+    assert_eq!(class.bind().a, 3);
+    assert_eq!(class.bind().call_count, 1);
+
+    class.call("set_b", &[5.to_variant()]);
+    assert_eq!(class.bind().b, 5);
+    assert_eq!(class.bind().call_count, 2);
+
+    class.free();
+}
+
+// ---------------------------------------------------------------