Repository: kedacore/keda (2025-07-11T20:19:06Z)

Issue:
Add support for an annotation to pause scale down on Scale Objects
### Proposal

We would like to add support for an annotation to ScaleObjects that tells KEDA to pause Scale Down both in the HPA and in its internal Scale loop. 

Today, to disable scale down, one has to change the ScaledObject's HPA behavior to do so and that has the effect of only disabling scale down with the HPA Algorithm; scale to zero when KEDA determines a scaler is in-active is still allowed.

This annotation will not only tell the ScaledObject reconciler to set the Scale Down Select policy on HPAs to Disabled, it will also prevent the RequestScale method from scaling down a scale target when the annotation is set.

### Use-Case

We want to be able to pause scale down while still allowing scale up as an operational feature.

### Is this a feature you are interested in implementing yourself?

Yes

### Anything else?

_No response_


Discussion:
I'm not sure this property is necessary, since preventing scaling down can also be handled by a modifier. Maybe pausing scaling for a certain period would be more practical?  WDYT @kedacore/keda-core-contributors 
The [docs](https://keda.sh/docs/2.17/concepts/scaling-deployments/#scaling-modifiers) aren't clear how a modifier would pause scale-down. Further, the situations in which we would use this annotation would be ones where we don't know how long we need to pause scale down, for example, metrics provider outages. This annotation gives users a quick way of pausing scale down without modifying Scaled Object configuration (well, aside from the annotation)
In general this could work, we just need to make sure to have consistency between this annotation and a setting in the HPA behavior section (one would tell us to stop scale down the other would say the opposite). Let's think about the best UX here.
Yea, I can make it clear in the documentation that this annotation is for operational purposes only and overrides whatever is in the behavior portion (as it also pauses scale down by the scale loop)



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f3a3f203e12..c29848ba7f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -64,6 +64,7 @@ To learn more about active deprecations, we recommend checking [GitHub Discussio
 ### New
 
 - **General**: Add error and event for mismatching input property ([#6721](https://github.com/kedacore/keda/issues/6721))
+- **General**: Add support for pause scale in annotation ([#6902](https://github.com/kedacore/keda/issues/6902))
 - **General**: Enable support on s390x for KEDA ([#6543](https://github.com/kedacore/keda/issues/6543))
 - **General**: Introduce new Solace Direct Messaging scaler ([#6545](https://github.com/kedacore/keda/issues/6545))
 - **General**: Introduce new Sumo Logic Scaler ([#6734](https://github.com/kedacore/keda/issues/6734))
diff --git a/apis/eventing/v1alpha1/zz_generated.deepcopy.go b/apis/eventing/v1alpha1/zz_generated.deepcopy.go
index 114764d49ba..e40d3451cec 100644
--- a/apis/eventing/v1alpha1/zz_generated.deepcopy.go
+++ b/apis/eventing/v1alpha1/zz_generated.deepcopy.go
@@ -82,6 +82,21 @@ func (in *CloudEventSource) DeepCopyObject() runtime.Object {
 	return nil
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *CloudEventSourceCustomValidator) DeepCopyInto(out *CloudEventSourceCustomValidator) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CloudEventSourceCustomValidator.
+func (in *CloudEventSourceCustomValidator) DeepCopy() *CloudEventSourceCustomValidator {
+	if in == nil {
+		return nil
+	}
+	out := new(CloudEventSourceCustomValidator)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *CloudEventSourceList) DeepCopyInto(out *CloudEventSourceList) {
 	*out = *in
@@ -183,6 +198,21 @@ func (in *ClusterCloudEventSource) DeepCopyObject() runtime.Object {
 	return nil
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ClusterCloudEventSourceCustomValidator) DeepCopyInto(out *ClusterCloudEventSourceCustomValidator) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterCloudEventSourceCustomValidator.
+func (in *ClusterCloudEventSourceCustomValidator) DeepCopy() *ClusterCloudEventSourceCustomValidator {
+	if in == nil {
+		return nil
+	}
+	out := new(ClusterCloudEventSourceCustomValidator)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *ClusterCloudEventSourceList) DeepCopyInto(out *ClusterCloudEventSourceList) {
 	*out = *in
diff --git a/apis/keda/v1alpha1/scaledobject_types.go b/apis/keda/v1alpha1/scaledobject_types.go
index a23d6e5e22c..60ef01f7210 100644
--- a/apis/keda/v1alpha1/scaledobject_types.go
+++ b/apis/keda/v1alpha1/scaledobject_types.go
@@ -57,6 +57,7 @@ const ScaledObjectExcludedLabelsAnnotation = "scaledobject.keda.sh/hpa-excluded-
 const ValidationsHpaOwnershipAnnotation = "validations.keda.sh/hpa-ownership"
 const PausedReplicasAnnotation = "autoscaling.keda.sh/paused-replicas"
 const PausedAnnotation = "autoscaling.keda.sh/paused"
+const PausedScaleInAnnotation = "autoscaling.keda.sh/paused-scale-in"
 const FallbackBehaviorStatic = "static"
 const FallbackBehaviorCurrentReplicas = "currentReplicas"
 const FallbackBehaviorCurrentReplicasIfHigher = "currentReplicasIfHigher"
@@ -230,16 +231,25 @@ func (so *ScaledObject) NeedToBePausedByAnnotation() bool {
 		return so.Status.PausedReplicaCount != nil
 	}
 
-	pausedAnnotationValue, pausedAnnotationFound := so.GetAnnotations()[PausedAnnotation]
-	if !pausedAnnotationFound {
+	return getBoolAnnotation(so, PausedAnnotation)
+}
+
+// NeedToPauseScaleIn checks whether Scale In actions for a ScaledObject need to be blocked based on the PausedScaleIn annotation
+func (so *ScaledObject) NeedToPauseScaleIn() bool {
+	return getBoolAnnotation(so, PausedScaleInAnnotation)
+}
+
+func getBoolAnnotation(so *ScaledObject, annotation string) bool {
+	value, found := so.GetAnnotations()[annotation]
+	if !found {
 		return false
 	}
-	shouldPause, err := strconv.ParseBool(pausedAnnotationValue)
+	boolVal, err := strconv.ParseBool(value)
 	if err != nil {
-		// if annotation value is not a boolean, we assume user wants to pause the ScaledObject
+		// if annotation value is not a boolean, we assume true
 		return true
 	}
-	return shouldPause
+	return boolVal
 }
 
 // IsUsingModifiers determines whether scalingModifiers are defined or not
diff --git a/apis/keda/v1alpha1/zz_generated.deepcopy.go b/apis/keda/v1alpha1/zz_generated.deepcopy.go
index 5248166f09f..866c18b5ace 100755
--- a/apis/keda/v1alpha1/zz_generated.deepcopy.go
+++ b/apis/keda/v1alpha1/zz_generated.deepcopy.go
@@ -396,6 +396,21 @@ func (in *ClusterTriggerAuthentication) DeepCopyObject() runtime.Object {
 	return nil
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ClusterTriggerAuthenticationCustomValidator) DeepCopyInto(out *ClusterTriggerAuthenticationCustomValidator) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterTriggerAuthenticationCustomValidator.
+func (in *ClusterTriggerAuthenticationCustomValidator) DeepCopy() *ClusterTriggerAuthenticationCustomValidator {
+	if in == nil {
+		return nil
+	}
+	out := new(ClusterTriggerAuthenticationCustomValidator)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *ClusterTriggerAuthenticationList) DeepCopyInto(out *ClusterTriggerAuthenticationList) {
 	*out = *in
@@ -733,6 +748,21 @@ func (in *ScaledJob) DeepCopyObject() runtime.Object {
 	return nil
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ScaledJobCustomValidator) DeepCopyInto(out *ScaledJobCustomValidator) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScaledJobCustomValidator.
+func (in *ScaledJobCustomValidator) DeepCopy() *ScaledJobCustomValidator {
+	if in == nil {
+		return nil
+	}
+	out := new(ScaledJobCustomValidator)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *ScaledJobList) DeepCopyInto(out *ScaledJobList) {
 	*out = *in
@@ -1142,6 +1172,21 @@ func (in *TriggerAuthentication) DeepCopyObject() runtime.Object {
 	return nil
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *TriggerAuthenticationCustomValidator) DeepCopyInto(out *TriggerAuthenticationCustomValidator) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TriggerAuthenticationCustomValidator.
+func (in *TriggerAuthenticationCustomValidator) DeepCopy() *TriggerAuthenticationCustomValidator {
+	if in == nil {
+		return nil
+	}
+	out := new(TriggerAuthenticationCustomValidator)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *TriggerAuthenticationList) DeepCopyInto(out *TriggerAuthenticationList) {
 	*out = *in
diff --git a/controllers/keda/hpa.go b/controllers/keda/hpa.go
index 31cbad6a2f5..c519074d7e0 100644
--- a/controllers/keda/hpa.go
+++ b/controllers/keda/hpa.go
@@ -84,6 +84,28 @@ func (r *ScaledObjectReconciler) newHPAForScaledObject(ctx context.Context, logg
 		behavior = nil
 	}
 
+	if scaledObject.NeedToPauseScaleIn() {
+		// If the paused-scale-in annotation is set, set the HPA ScaleDown Select policy to Disabled
+		// to prevent the HPA from scaling down the scale target
+		if behavior == nil {
+			behavior = &autoscalingv2.HorizontalPodAutoscalerBehavior{}
+		}
+		if behavior.ScaleDown == nil {
+			behavior.ScaleDown = &autoscalingv2.HPAScalingRules{}
+		}
+
+		disabledPolicy := autoscalingv2.DisabledPolicySelect
+		behavior.ScaleDown.SelectPolicy = &disabledPolicy
+
+		logger.Info(
+			"Scale in paused by annotation, setting HPA Scale Down Select Behavior to Disabled",
+			"HPA.Namespace",
+			scaledObject.Namespace,
+			"HPA.Name",
+			getHPAName(scaledObject),
+		)
+	}
+
 	// label can have max 63 chars
 	labelName := getHPAName(scaledObject)
 	if len(labelName) > 63 {
diff --git a/controllers/keda/scaledobject_controller.go b/controllers/keda/scaledobject_controller.go
index b6b6d4486a7..ae908245fb9 100755
--- a/controllers/keda/scaledobject_controller.go
+++ b/controllers/keda/scaledobject_controller.go
@@ -131,6 +131,7 @@ func (r *ScaledObjectReconciler) SetupWithManager(mgr ctrl.Manager, options cont
 			predicate.Or(
 				kedacontrollerutil.PausedPredicate{},
 				kedacontrollerutil.PausedReplicasPredicate{},
+				kedacontrollerutil.PausedScaleInPredicate{},
 				kedacontrollerutil.ScaleObjectReadyConditionPredicate{},
 				predicate.GenerationChangedPredicate{},
 			),
@@ -226,10 +227,16 @@ func (r *ScaledObjectReconciler) Reconcile(ctx context.Context, req ctrl.Request
 
 // reconcileScaledObject implements reconciler logic for ScaledObject
 func (r *ScaledObjectReconciler) reconcileScaledObject(ctx context.Context, logger logr.Logger, scaledObject *kedav1alpha1.ScaledObject, conditions *kedav1alpha1.Conditions) (string, error) {
-	// Check the presence of "autoscaling.keda.sh/paused" annotation on the scaledObject (since the presence of this annotation will pause
-	// autoscaling no matter what number of replicas is provided), and if so, stop the scale loop and delete the HPA on the scaled object.
+	// Check the presence of  the following annotations on the scaledObject:
+	// - "autoscaling.keda.sh/paused"
+	// - "autoscaling.keda.sh/paused-replicas"
+	// and if so, stop the scale loop and delete the HPA on the scaledObject.
+	// Additionally, if the following annotations are present:
+	// - "autoscaling.keda.sh/paused-scale-in"
+	// we also set the status to paused but we allow the scale loop to continue and do not delete the HPA because these are unidirectional pauses.
 	needsToPause := scaledObject.NeedToBePausedByAnnotation()
-	if needsToPause {
+	switch {
+	case needsToPause:
 		scaledToPausedCount := true
 		if conditions.GetPausedCondition().Status == metav1.ConditionTrue {
 			// If scaledobject is in paused condition but replica count is not equal to paused replica count, the following scaling logic needs to be trigger again.
@@ -251,7 +258,9 @@ func (r *ScaledObjectReconciler) reconcileScaledObject(ctx context.Context, logg
 			conditions.SetPausedCondition(metav1.ConditionTrue, kedav1alpha1.ScaledObjectConditionPausedReason, msg)
 			return msg, nil
 		}
-	} else if conditions.GetPausedCondition().Status == metav1.ConditionTrue {
+	case scaledObject.NeedToPauseScaleIn():
+		conditions.SetPausedCondition(metav1.ConditionTrue, kedav1alpha1.ScaledObjectConditionPausedReason, kedav1alpha1.ScaledObjectConditionPausedMessage)
+	case conditions.GetPausedCondition().Status == metav1.ConditionTrue:
 		conditions.SetPausedCondition(metav1.ConditionFalse, "ScaledObjectUnpaused", "pause annotation removed for ScaledObject")
 	}
 
diff --git a/controllers/keda/util/predicate.go b/controllers/keda/util/predicate.go
index 1ecc114d3ed..e04a464d6ce 100644
--- a/controllers/keda/util/predicate.go
+++ b/controllers/keda/util/predicate.go
@@ -14,25 +14,7 @@ type PausedReplicasPredicate struct {
 }
 
 func (PausedReplicasPredicate) Update(e event.UpdateEvent) bool {
-	if e.ObjectOld == nil || e.ObjectNew == nil {
-		return false
-	}
-
-	newAnnotations := e.ObjectNew.GetAnnotations()
-	oldAnnotations := e.ObjectOld.GetAnnotations()
-
-	newPausedValue := ""
-	oldPausedValue := ""
-
-	if newAnnotations != nil {
-		newPausedValue = newAnnotations[kedav1alpha1.PausedReplicasAnnotation]
-	}
-
-	if oldAnnotations != nil {
-		oldPausedValue = oldAnnotations[kedav1alpha1.PausedReplicasAnnotation]
-	}
-
-	return newPausedValue != oldPausedValue
+	return checkAnnotation(e, kedav1alpha1.PausedReplicasAnnotation)
 }
 
 type ScaleObjectReadyConditionPredicate struct {
@@ -71,6 +53,18 @@ type PausedPredicate struct {
 }
 
 func (PausedPredicate) Update(e event.UpdateEvent) bool {
+	return checkAnnotation(e, kedav1alpha1.PausedAnnotation)
+}
+
+type PausedScaleInPredicate struct {
+	predicate.Funcs
+}
+
+func (PausedScaleInPredicate) Update(e event.UpdateEvent) bool {
+	return checkAnnotation(e, kedav1alpha1.PausedScaleInAnnotation)
+}
+
+func checkAnnotation(e event.UpdateEvent, annotation string) bool {
 	if e.ObjectOld == nil || e.ObjectNew == nil {
 		return false
 	}
@@ -78,18 +72,18 @@ func (PausedPredicate) Update(e event.UpdateEvent) bool {
 	newAnnotations := e.ObjectNew.GetAnnotations()
 	oldAnnotations := e.ObjectOld.GetAnnotations()
 
-	newPausedValue := ""
-	oldPausedValue := ""
+	newValue := ""
+	oldValue := ""
 
 	if newAnnotations != nil {
-		newPausedValue = newAnnotations[kedav1alpha1.PausedAnnotation]
+		newValue = newAnnotations[annotation]
 	}
 
 	if oldAnnotations != nil {
-		oldPausedValue = oldAnnotations[kedav1alpha1.PausedAnnotation]
+		oldValue = oldAnnotations[annotation]
 	}
 
-	return newPausedValue != oldPausedValue
+	return newValue != oldValue
 }
 
 type HPASpecChangedPredicate struct {
diff --git a/pkg/scaling/executor/scale_scaledobjects.go b/pkg/scaling/executor/scale_scaledobjects.go
index 46b85ca9a23..726bb607f85 100644
--- a/pkg/scaling/executor/scale_scaledobjects.go
+++ b/pkg/scaling/executor/scale_scaledobjects.go
@@ -206,8 +206,14 @@ func (e *scaleExecutor) RequestScale(ctx context.Context, scaledObject *kedav1al
 }
 
 // An object will be scaled down to 0 only if it's passed its cooldown period
-// or if LastActiveTime is nil
+// or if LastActiveTime is nil and scale in is not paused
 func (e *scaleExecutor) scaleToZeroOrIdle(ctx context.Context, logger logr.Logger, scaledObject *kedav1alpha1.ScaledObject, scale *autoscalingv1.Scale) {
+	if scaledObject.NeedToPauseScaleIn() {
+		// The Pause Scale Down annotation is set so we should not scale down this target
+		logger.Info("Pause Scale Down annotation set on ScaledObject, no scaling down on inactive trigger")
+		return
+	}
+
 	var initialCooldownPeriod, cooldownPeriod time.Duration
 
 	if scaledObject.Spec.InitialCooldownPeriod != nil {


Test patch:
diff --git a/controllers/keda/scaledobject_controller_test.go b/controllers/keda/scaledobject_controller_test.go
index 18eb1ad8147..3fe12d8bfb9 100644
--- a/controllers/keda/scaledobject_controller_test.go
+++ b/controllers/keda/scaledobject_controller_test.go
@@ -582,6 +582,233 @@ var _ = Describe("ScaledObjectController", func() {
 			Ω(hpa.Annotations).To(Equal(so.Annotations))
 		})
 
+		It("deploys ScaledObject and creates HPA with scale down select policy disabled when pause scale-in annotation set", func() {
+			deploymentName := "disable-scale-in"
+			soName := "so-" + deploymentName
+
+			// Create the scaling target.
+			err := k8sClient.Create(context.Background(), generateDeployment(deploymentName))
+			Expect(err).ToNot(HaveOccurred())
+
+			// Create the ScaledObject
+			so := &kedav1alpha1.ScaledObject{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      soName,
+					Namespace: "default",
+					Annotations: map[string]string{
+						kedav1alpha1.PausedScaleInAnnotation: "true",
+					}},
+				Spec: kedav1alpha1.ScaledObjectSpec{
+					ScaleTargetRef: &kedav1alpha1.ScaleTarget{
+						Name: deploymentName,
+					},
+					Triggers: []kedav1alpha1.ScaleTriggers{
+						{
+							Type: "cron",
+							Metadata: map[string]string{
+								"timezone":        "UTC",
+								"start":           "0 * * * *",
+								"end":             "1 * * * *",
+								"desiredReplicas": "1",
+							},
+						},
+					},
+				},
+			}
+			err = k8sClient.Create(context.Background(), so)
+			Ω(err).ToNot(HaveOccurred())
+
+			Eventually(func() metav1.ConditionStatus {
+				err = k8sClient.Get(context.Background(), types.NamespacedName{Name: soName, Namespace: "default"}, so)
+				Ω(err).ToNot(HaveOccurred())
+				return so.Status.Conditions.GetPausedCondition().Status
+			}, 5*time.Second).Should(Equal(metav1.ConditionTrue))
+
+			// Get and confirm the HPA
+			hpa := &autoscalingv2.HorizontalPodAutoscaler{}
+			Eventually(func() error {
+				return k8sClient.Get(context.Background(), types.NamespacedName{Name: "keda-hpa-" + soName, Namespace: "default"}, hpa)
+			}).ShouldNot(HaveOccurred())
+
+			Ω(*hpa.Spec.Behavior.ScaleDown.SelectPolicy).To(Equal(autoscalingv2.DisabledPolicySelect))
+		})
+
+		It("sets scale down select policy to disable when annotation added to existing object", func() {
+			deploymentName := "disable-scale-in-old-so"
+			soName := "so-" + deploymentName
+
+			// Create the scaling target.
+			err := k8sClient.Create(context.Background(), generateDeployment(deploymentName))
+			Expect(err).ToNot(HaveOccurred())
+
+			minReplicaCount := int32(1)
+			maxReplicaCount := int32(10)
+
+			// Create the ScaledObject with 1 trigger.
+			so := &kedav1alpha1.ScaledObject{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:        soName,
+					Namespace:   "default",
+					Annotations: make(map[string]string),
+				},
+				Spec: kedav1alpha1.ScaledObjectSpec{
+					ScaleTargetRef: &kedav1alpha1.ScaleTarget{
+						Name: deploymentName,
+					},
+					Triggers: []kedav1alpha1.ScaleTriggers{
+						{
+							Type: "cron",
+							Metadata: map[string]string{
+								"timezone":        "UTC",
+								"start":           "0 * * * *",
+								"end":             "1 * * * *",
+								"desiredReplicas": "2",
+							},
+						},
+					},
+					MinReplicaCount: &minReplicaCount,
+					MaxReplicaCount: &maxReplicaCount,
+				},
+			}
+			err = k8sClient.Create(context.Background(), so)
+			Expect(err).ToNot(HaveOccurred())
+
+			testLogger.Info("Created scaled object")
+
+			// Get and confirm the HPA.
+			hpa := &autoscalingv2.HorizontalPodAutoscaler{}
+			Eventually(func() error {
+				return k8sClient.Get(context.Background(), types.NamespacedName{Name: "keda-hpa-" + soName, Namespace: "default"}, hpa)
+			}).ShouldNot(HaveOccurred())
+			Expect(hpa.Spec.Behavior).To(Equal((*autoscalingv2.HorizontalPodAutoscalerBehavior)(nil)))
+
+			Eventually(func() metav1.ConditionStatus {
+				err = k8sClient.Get(context.Background(), types.NamespacedName{Name: soName, Namespace: "default"}, so)
+				Ω(err).ToNot(HaveOccurred())
+				return so.Status.Conditions.GetPausedCondition().Status
+			}, 2*time.Minute).WithPolling(5 * time.Second).Should(Or(Equal(metav1.ConditionFalse), Equal(metav1.ConditionUnknown)))
+
+			// Set the annotation
+			Eventually(func() error {
+				err = k8sClient.Get(context.Background(), types.NamespacedName{Name: soName, Namespace: "default"}, so)
+				Expect(err).ToNot(HaveOccurred())
+
+				if so.Annotations == nil {
+					so.Annotations = make(map[string]string)
+				}
+				so.Annotations[kedav1alpha1.PausedScaleInAnnotation] = "true"
+
+				return k8sClient.Update(context.Background(), so)
+			}).ShouldNot(HaveOccurred())
+
+			Eventually(func() metav1.ConditionStatus {
+				err = k8sClient.Get(context.Background(), types.NamespacedName{Name: soName, Namespace: "default"}, so)
+				Ω(err).ToNot(HaveOccurred())
+				return so.Status.Conditions.GetPausedCondition().Status
+			}, 2*time.Minute).WithPolling(5 * time.Second).Should(Equal(metav1.ConditionTrue))
+
+			Eventually(func() autoscalingv2.ScalingPolicySelect {
+				err := k8sClient.Get(context.Background(), types.NamespacedName{Name: "keda-hpa-" + soName, Namespace: "default"}, hpa)
+				Expect(err).ToNot(HaveOccurred())
+				if hpa.Spec.Behavior != nil && hpa.Spec.Behavior.ScaleDown != nil && hpa.Spec.Behavior.ScaleDown.SelectPolicy != nil {
+					return *hpa.Spec.Behavior.ScaleDown.SelectPolicy
+				} else {
+					return ""
+				}
+			}).WithTimeout(1 * time.Minute).WithPolling(10 * time.Second).Should(Equal(autoscalingv2.DisabledPolicySelect))
+		})
+
+		It("sets scale down select policy to original policy when annotation removed", func() {
+			deploymentName := "remove-scale-in-disabled-annotation"
+			soName := "so-" + deploymentName
+
+			// Create the scaling target.
+			err := k8sClient.Create(context.Background(), generateDeployment(deploymentName))
+			Expect(err).ToNot(HaveOccurred())
+
+			// Create the ScaledObject with 1 trigger.
+			minPolicy := autoscalingv2.MinChangePolicySelect
+			minReplicaCount := int32(1)
+			maxReplicaCount := int32(10)
+			so := &kedav1alpha1.ScaledObject{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      soName,
+					Namespace: "default",
+					Annotations: map[string]string{
+						kedav1alpha1.PausedScaleInAnnotation: "true",
+					},
+				},
+
+				Spec: kedav1alpha1.ScaledObjectSpec{
+					ScaleTargetRef: &kedav1alpha1.ScaleTarget{
+						Name: deploymentName,
+					},
+					MinReplicaCount: &minReplicaCount,
+					MaxReplicaCount: &maxReplicaCount,
+					Triggers: []kedav1alpha1.ScaleTriggers{
+						{
+							Type: "cron",
+							Metadata: map[string]string{
+								"timezone":        "UTC",
+								"start":           "0 * * * *",
+								"end":             "1 * * * *",
+								"desiredReplicas": "2",
+							},
+						},
+					},
+					Advanced: &kedav1alpha1.AdvancedConfig{
+						HorizontalPodAutoscalerConfig: &kedav1alpha1.HorizontalPodAutoscalerConfig{
+							Behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{
+								ScaleDown: &autoscalingv2.HPAScalingRules{
+									SelectPolicy: &minPolicy,
+								},
+							},
+						},
+					},
+				},
+			}
+			err = k8sClient.Create(context.Background(), so)
+			Expect(err).ToNot(HaveOccurred())
+			testLogger.Info("Created scaled object")
+
+			// Get and confirm the HPA.
+			hpa := &autoscalingv2.HorizontalPodAutoscaler{}
+			Eventually(func() error {
+				return k8sClient.Get(context.Background(), types.NamespacedName{Name: "keda-hpa-" + soName, Namespace: "default"}, hpa)
+			}).ShouldNot(HaveOccurred())
+			Expect(*hpa.Spec.Behavior.ScaleDown.SelectPolicy).To(Equal(autoscalingv2.DisabledPolicySelect))
+
+			Eventually(func() metav1.ConditionStatus {
+				err = k8sClient.Get(context.Background(), types.NamespacedName{Name: soName, Namespace: "default"}, so)
+				Ω(err).ToNot(HaveOccurred())
+				return so.Status.Conditions.GetPausedCondition().Status
+			}, 2*time.Minute).WithPolling(5 * time.Second).Should(Equal(metav1.ConditionTrue))
+
+			// Remove the annotation.
+			Eventually(func() error {
+				err = k8sClient.Get(context.Background(), types.NamespacedName{Name: soName, Namespace: "default"}, so)
+				Expect(err).ToNot(HaveOccurred())
+				delete(so.ObjectMeta.Annotations, kedav1alpha1.PausedScaleInAnnotation)
+				return k8sClient.Update(context.Background(), so)
+			}).ShouldNot(HaveOccurred())
+
+			Eventually(func() metav1.ConditionStatus {
+				err = k8sClient.Get(context.Background(), types.NamespacedName{Name: soName, Namespace: "default"}, so)
+				Ω(err).ToNot(HaveOccurred())
+				return so.Status.Conditions.GetPausedCondition().Status
+			}, 2*time.Minute).WithPolling(5 * time.Second).Should(Or(Equal(metav1.ConditionFalse), Equal(metav1.ConditionUnknown)))
+
+			Eventually(func() autoscalingv2.ScalingPolicySelect {
+				err := k8sClient.Get(context.Background(), types.NamespacedName{Name: "keda-hpa-" + soName, Namespace: "default"}, hpa)
+				Expect(err).ToNot(HaveOccurred())
+				if hpa.Spec.Behavior != nil && hpa.Spec.Behavior.ScaleDown != nil && hpa.Spec.Behavior.ScaleDown.SelectPolicy != nil {
+					return *hpa.Spec.Behavior.ScaleDown.SelectPolicy
+				} else {
+					return ""
+				}
+			}).WithTimeout(1 * time.Minute).WithPolling(10 * time.Second).Should(Equal(autoscalingv2.MinChangePolicySelect))
+		})
+
 		It("doesn't allow MinReplicaCount > MaxReplicaCount", func() {
 			deploymentName := "minmax"
 			soName := "so-" + deploymentName
diff --git a/pkg/scaling/executor/scale_scaledobjects_test.go b/pkg/scaling/executor/scale_scaledobjects_test.go
index de0d9ea5891..fc5a50acfbc 100644
--- a/pkg/scaling/executor/scale_scaledobjects_test.go
+++ b/pkg/scaling/executor/scale_scaledobjects_test.go
@@ -460,3 +460,135 @@ func TestEventWitTriggerInfo(t *testing.T) {
 	eventstring := <-recorder.Events
 	assert.Equal(t, "Normal KEDAScaleTargetActivated Scaled  namespace/name from 2 to 5, triggered by testTrigger", eventstring)
 }
+
+func TestNoScaleToMinReplicasWhenNotActiveAndPauseScaleInAnnotationSet(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	client := mock_client.NewMockClient(ctrl)
+	recorder := record.NewFakeRecorder(1)
+	mockScaleClient := mock_scale.NewMockScalesGetter(ctrl)
+	mockScaleInterface := mock_scale.NewMockScaleInterface(ctrl)
+	statusWriter := mock_client.NewMockStatusWriter(ctrl)
+
+	scaleExecutor := NewScaleExecutor(client, mockScaleClient, nil, recorder)
+
+	minReplicas := int32(0)
+
+	scaledObject := v1alpha1.ScaledObject{
+		ObjectMeta: v1.ObjectMeta{
+			Name:      "name",
+			Namespace: "namespace",
+			Annotations: map[string]string{
+				v1alpha1.PausedScaleInAnnotation: "true",
+			},
+		},
+		Spec: v1alpha1.ScaledObjectSpec{
+			ScaleTargetRef: &v1alpha1.ScaleTarget{
+				Name: "name",
+			},
+			MinReplicaCount: &minReplicas,
+		},
+		Status: v1alpha1.ScaledObjectStatus{
+			ScaleTargetGVKR: &v1alpha1.GroupVersionKindResource{
+				Group: "apps",
+				Kind:  "Deployment",
+			},
+		},
+	}
+
+	scaledObject.Status.Conditions = *v1alpha1.GetInitializedConditions()
+
+	numberOfReplicas := int32(10)
+
+	client.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).SetArg(2, appsv1.Deployment{
+		Spec: appsv1.DeploymentSpec{
+			Replicas: &numberOfReplicas,
+		},
+	})
+
+	scale := &autoscalingv1.Scale{
+		Spec: autoscalingv1.ScaleSpec{
+			Replicas: numberOfReplicas,
+		},
+	}
+
+	// Expect no calls to Scale
+	mockScaleClient.EXPECT().Scales(gomock.Any()).Return(mockScaleInterface).Times(0)
+	mockScaleInterface.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0)
+	mockScaleInterface.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0)
+
+	client.EXPECT().Status().Return(statusWriter).Times(2)
+	statusWriter.EXPECT().Patch(gomock.Any(), gomock.Any(), gomock.Any()).Times(2)
+
+	scaleExecutor.RequestScale(context.TODO(), &scaledObject, false, false, &ScaleExecutorOptions{})
+
+	assert.Equal(t, numberOfReplicas, scale.Spec.Replicas)
+	condition := scaledObject.Status.Conditions.GetActiveCondition()
+	assert.Equal(t, true, condition.IsFalse())
+}
+
+func TestNoScaleToIdleReplicasWhenNotActiveAndPauseScaleInAnnotationSet(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	client := mock_client.NewMockClient(ctrl)
+	recorder := record.NewFakeRecorder(1)
+	mockScaleClient := mock_scale.NewMockScalesGetter(ctrl)
+	mockScaleInterface := mock_scale.NewMockScaleInterface(ctrl)
+	statusWriter := mock_client.NewMockStatusWriter(ctrl)
+
+	scaleExecutor := NewScaleExecutor(client, mockScaleClient, nil, recorder)
+
+	idleReplicas := int32(0)
+	minReplicas := int32(5)
+
+	scaledObject := v1alpha1.ScaledObject{
+		ObjectMeta: v1.ObjectMeta{
+			Name:      "name",
+			Namespace: "namespace",
+			Annotations: map[string]string{
+				v1alpha1.PausedScaleInAnnotation: "true",
+			},
+		},
+		Spec: v1alpha1.ScaledObjectSpec{
+			ScaleTargetRef: &v1alpha1.ScaleTarget{
+				Name: "name",
+			},
+			IdleReplicaCount: &idleReplicas,
+			MinReplicaCount:  &minReplicas,
+		},
+		Status: v1alpha1.ScaledObjectStatus{
+			ScaleTargetGVKR: &v1alpha1.GroupVersionKindResource{
+				Group: "apps",
+				Kind:  "Deployment",
+			},
+		},
+	}
+
+	scaledObject.Status.Conditions = *v1alpha1.GetInitializedConditions()
+
+	numberOfReplicas := int32(10)
+
+	client.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).SetArg(2, appsv1.Deployment{
+		Spec: appsv1.DeploymentSpec{
+			Replicas: &numberOfReplicas,
+		},
+	})
+
+	scale := &autoscalingv1.Scale{
+		Spec: autoscalingv1.ScaleSpec{
+			Replicas: numberOfReplicas,
+		},
+	}
+
+	// Expect no calls to Scale
+	mockScaleClient.EXPECT().Scales(gomock.Any()).Return(mockScaleInterface).Times(0)
+	mockScaleInterface.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0)
+	mockScaleInterface.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0)
+
+	client.EXPECT().Status().Return(statusWriter).Times(2)
+	statusWriter.EXPECT().Patch(gomock.Any(), gomock.Any(), gomock.Any()).Times(2)
+
+	scaleExecutor.RequestScale(context.TODO(), &scaledObject, false, false, &ScaleExecutorOptions{})
+
+	assert.Equal(t, numberOfReplicas, scale.Spec.Replicas)
+	condition := scaledObject.Status.Conditions.GetActiveCondition()
+	assert.Equal(t, true, condition.IsFalse())
+}
diff --git a/tests/internals/pause_scale_in/pause_scale_in_test.go b/tests/internals/pause_scale_in/pause_scale_in_test.go
new file mode 100644
index 00000000000..d94878e6fb3
--- /dev/null
+++ b/tests/internals/pause_scale_in/pause_scale_in_test.go
@@ -0,0 +1,131 @@
+//go:build e2e
+// +build e2e
+
+package pause_scale_in_test
+
+import (
+	"fmt"
+	"testing"
+
+	. "github.com/kedacore/keda/v2/tests/helper"
+)
+
+// Load environment variables from .env file
+
+const (
+	testName = "pause-scalein-test"
+)
+
+var (
+	testNamespace           = fmt.Sprintf("%s-ns", testName)
+	deploymentName          = fmt.Sprintf("%s-deployment", testName)
+	monitoredDeploymentName = fmt.Sprintf("%s-monitored", testName)
+	scaledObjectName        = fmt.Sprintf("%s-so", testName)
+)
+
+type templateData struct {
+	TestNamespace           string
+	DeploymentName          string
+	ScaledObjectName        string
+	MonitoredDeploymentName string
+}
+
+const (
+	monitoredDeploymentTemplate = `
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{.MonitoredDeploymentName}}
+  namespace: {{.TestNamespace}}
+  labels:
+    app: {{.MonitoredDeploymentName}}
+spec:
+  replicas: 0
+  selector:
+    matchLabels:
+      app: {{.MonitoredDeploymentName}}
+  template:
+    metadata:
+      labels:
+        app: {{.MonitoredDeploymentName}}
+    spec:
+      containers:
+        - name: {{.MonitoredDeploymentName}}
+          image: ghcr.io/nginx/nginx-unprivileged:1.26
+`
+
+	deploymentTemplate = `
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{.DeploymentName}}
+  namespace: {{.TestNamespace}}
+  labels:
+    app: {{.DeploymentName}}
+spec:
+  replicas: 2
+  selector:
+    matchLabels:
+      app: {{.DeploymentName}}
+  template:
+    metadata:
+      labels:
+        app: {{.DeploymentName}}
+    spec:
+      containers:
+        - name: {{.DeploymentName}}
+          image: ghcr.io/nginx/nginx-unprivileged:1.26
+`
+
+	scaledObjectTemplate = `
+apiVersion: keda.sh/v1alpha1
+kind: ScaledObject
+metadata:
+  name: {{.ScaledObjectName}}
+  namespace: {{.TestNamespace}}
+  annotations:
+    autoscaling.keda.sh/paused-scale-in: "true"
+spec:
+  scaleTargetRef:
+    name: {{.DeploymentName}}
+  pollingInterval: 5
+  minReplicaCount: 0
+  maxReplicaCount: 3
+  cooldownPeriod:  0
+  triggers:
+    - type: kubernetes-workload
+      metadata:
+        podSelector: 'app={{.MonitoredDeploymentName}}'
+        value: '1'
+`
+)
+
+func TestScaler(t *testing.T) {
+	// setup
+	t.Log("--- setting up ---")
+
+	// Create kubernetes resources
+	kc := GetKubernetesClient(t)
+	data, templates := getTemplateData()
+
+	CreateKubernetesResources(t, kc, testNamespace, data, templates)
+
+	// assert that the deployment did not scale down after one minute
+	AssertReplicaCountNotChangeDuringTimePeriod(t, kc, deploymentName, testNamespace, 2, 60)
+
+	// cleanup
+	DeleteKubernetesResources(t, testNamespace, data, templates)
+}
+
+func getTemplateData() (templateData, []Template) {
+	return templateData{
+			TestNamespace:           testNamespace,
+			DeploymentName:          deploymentName,
+			ScaledObjectName:        scaledObjectName,
+			MonitoredDeploymentName: monitoredDeploymentName,
+		}, []Template{
+			{Name: "deploymentTemplate", Config: deploymentTemplate},
+			{Name: "monitoredDeploymentTemplate", Config: monitoredDeploymentTemplate},
+			{Name: "scaledObjectAnnotatedTemplate", Config: scaledObjectTemplate},
+		}
+}