Topology spread constraints: maxSkew, zones and scale-down

What maxSkew, whenUnsatisfiable and minDomains do, how spread differs from anti-affinity, and when a rule holds a node.

Published 1 November 2026 · By Harsh, Guest author · Network engineer at Palo Alto Networks · Published by Zoorik

Topology spread constraints: pods spread over zone A, B and C, and the next pod goes to zone C, which has the fewest

Short answer. Topology spread constraints tell the kube-scheduler how unevenly pods may sit across zones or nodes, with maxSkew as the largest allowed gap between domains. If no node fits within it, DoNotSchedule leaves the pod Pending and can block scale-down, while ScheduleAnyway places it and only prefers the better spread.

How topology spread constraints decide where a pod goes

In the Kubernetes docs’ example, zone A holds 2 matching pods and zone B holds 1, under a maxSkew of 1 and DoNotSchedule. A new pod in zone A would make the counts 3 and 1, a skew of 2. So the pod can only go to zone B.

If zone B has no room, the pod stays Pending, however much room zone A has. A drain runs the same count for every evicted pod, so a spread rule can also keep a node, and its cost, in your cluster.

Pod topology spread constraints live in the pod’s spec.topologySpreadConstraints, which for a Deployment means its pod template. Each entry names a node label as its topologyKey. Nodes that share a value of that label form one domain: each zone for topology.kubernetes.io/zone, each node for kubernetes.io/hostname.

Nodes without the label are skipped, both in the count and as places to run the pod.

maxSkew and the global minimum

With DoNotSchedule, the scheduler checks each candidate node. It adds the new pod to that node’s domain count, then subtracts the global minimum: the lowest count in any eligible domain. A node whose result is above maxSkew is filtered out.

In the docs’ example that minimum is 1, in zone B. Zone A gives 2 (2 + 1 − 1), above the limit. Zone B gives 1 (1 + 1 − 1), which is allowed.

With maxSkew 1, the new pod can only join zone B Two zone boxes side by side. Zone A holds 2 pods. Zone B holds 1 pod and carries the tag global minimum 1. A highlighted new pod sits above both zones with a route to each. The route to zone A is dashed and ends in a cross, labelled 2 + 1 − 1 = 2, above maxSkew 1. The route to zone B is solid and ends in an arrowhead, labelled 1 + 1 − 1 = 1, allowed, with a tick. With maxSkew 1, the new pod can only join zone B new pod2 + 1 − 1 = 2above maxSkew 11 + 1 − 1 = 1allowedZone A2 podsZone B1 podglobal minimum 1
The Kubernetes docs’ example: maxSkew 1 and DoNotSchedule across two zones.

With a maxSkew of 2, either zone works.

whenUnsatisfiable: DoNotSchedule or ScheduleAnyway

DoNotSchedule is a hard rule. If no node keeps the skew within maxSkew, the pod stays Pending. Its FailedScheduling event gives the reason “node(s) didn’t match pod topology spread constraints”.

ScheduleAnyway is a preference. Kube-scheduler still places the pod, and gives priority to nodes that reduce the skew. The EKS best practices guide advises the hard setting only if it’s better for pods not to run than to break the spread.

The fields, and a Deployment that uses them

FieldWhat it does
maxSkew
How far a domain’s count of matching pods may run ahead of the lowest count. Must be greater than 0.
topologyKey
The node label that defines a domain.
whenUnsatisfiable
DoNotSchedule makes the rule a filter, so the pod waits. ScheduleAnyway makes it a score, so the pod lands.
labelSelector
Which pods are counted. Only pods in the incoming pod’s own namespace count.
minDomains
How many domains to expect. With fewer, the lowest count is taken as 0. DoNotSchedule only, and no feature gate needed from Kubernetes 1.30.
matchLabelKeys
Label keys whose values are copied from the incoming pod into the selector. Beta, on by default since 1.27.
nodeAffinityPolicy
Honor, the default, counts only nodes that match the pod’s node affinity or nodeSelector. Stable since 1.33.
nodeTaintsPolicy
Ignore, the default, counts every node. Honor leaves out nodes with taints the pod doesn’t tolerate. Stable since 1.33.

Entries combine with a logical AND, so a node must satisfy every one. Below, a Deployment keeps the zone counts of its 6 replicas within 1 of each other as pods are placed. Its second entry prefers to spread them across nodes.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 6
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      topologySpreadConstraints:
      - maxSkew: 1
        topologyKey: topology.kubernetes.io/zone
        whenUnsatisfiable: DoNotSchedule
        labelSelector:
          matchLabels:
            app: web
        matchLabelKeys:
        - pod-template-hash
        nodeTaintsPolicy: Honor
      - maxSkew: 1
        topologyKey: kubernetes.io/hostname
        whenUnsatisfiable: ScheduleAnyway
        labelSelector:
          matchLabels:
            app: web
      containers:
      - name: web
        image: <image>

Both selectors match the pod’s own labels on purpose. A pod outside its own selector doesn’t count itself. Such “ghost pods” can pile up in one domain, the docs warn.

pod-template-hash is the label the Deployment controller adds per revision, so each revision spreads on its own during a rollout.

Pods get a soft spread even without a rule

With no cluster defaults set, kube-scheduler acts as if each pod without its own constraints had two soft ones. They are maxSkew 3 on kubernetes.io/hostname and maxSkew 5 on topology.kubernetes.io/zone, both ScheduleAnyway.

These built-in defaults have been stable since Kubernetes 1.24. They apply only to pods that belong to a Service, ReplicaSet, StatefulSet or ReplicationController. A scheduler profile can replace them through the PodTopologySpread plugin’s arguments.

Topology spread constraints on AKS

AKS tightens the zone default. Microsoft’s zone resiliency guidance says that from Kubernetes 1.33, the AKS default scheduler uses maxSkew 1 for topology.kubernetes.io/zone. The rule is still ScheduleAnyway.

Set your own constraints in the pod spec where a workload needs more.

A strict zone rule can leave pods Pending even after a scale-up, the same page explains. The Cluster Autoscaler “isn’t zone-aware”. Virtual Machine Scale Sets pick the zone for a new node in a pool that spans zones.

So a scale-up can add a node in a zone that still breaks a DoNotSchedule rule. Microsoft’s answer is one node pool per zone, with --balance-similar-node-groups set to true.

That flag balances the pools only at scale-up, the Cluster Autoscaler FAQ notes. Scale-down ignores their relative sizes.

Topology spread constraints vs anti-affinity: spread allows a bounded gap

Pod anti-affinity also keeps replicas apart, but it only asks whether a domain already holds a matching pod. The Kubernetes docs state the limit: a required rule allows only a single pod in each topology domain. A preferred rule gives up the ability to enforce it.

A spread constraint sets a bounded gap instead. A zone or node can hold several replicas, as long as none runs more than maxSkew ahead of the lowest.

The difference shows when nodes go. Take 3 replicas on a 3-node pool.

With required anti-affinity on kubernetes.io/hostname, each replica needs a node to itself. So the pool can’t go below 3 nodes, however little the replicas request.

Use a hostname spread instead, with a maxSkew of 1, DoNotSchedule and nodeTaintsPolicy set to Honor. When the third node is drained, its replica can join one of the other two. Counts of 2 and 1 are within the limit.

Without Honor, the draining node still counts with 0 pods, as the scale-down section below explains. Both rules spread replicas when there’s room. Only the spread constraint lets this pool go from 3 nodes to 2.

Only the spread rule lets the drained node's replica double up Two panels side by side, each the same 3-node pool, node 1, node 2 and node 3, with one replica on each node. Node 3 is drawn dashed as draining, and its replica is highlighted. Left panel, Required anti-affinity: dashed routes from node 3's replica to node 1 and to node 2 end in crosses, labelled already holds a replica. Under the nodes: pool stays at 3 nodes. Right panel, Spread: maxSkew 1, nodeTaintsPolicy Honor, where node 3 is tagged draining, not counted: one solid route from node 3's replica ends in a dashed slot beside node 1's replica, labelled counts 2 and 1, within maxSkew 1, with a tick. Under the nodes: pool goes from 3 nodes to 2. Only the spread rule lets the drained node's replica double up Required anti-affinitynode 1node 2node 3drainingalready holds a replicapool stays at 3 nodesSpread: maxSkew 1, nodeTaintsPolicy Honornode 1node 2node 3draining, not countedcounts 2 and 1, within maxSkew 1pool goes from 3 nodes to 2
Illustration: 3 replicas on a 3-node pool, one rule per panel, with hostname as the domain.

Pod affinity vs node affinity covers the affinity rules themselves.

A hard spread rule can keep a node from scaling down

The Cluster Autoscaler simulates kube-scheduler behavior, its FAQ says. In the FAQ’s words, “if there’s no other node where a given pod can schedule, the pod’s node won’t be scaled down.”

Spread is part of that decision. A DoNotSchedule rule narrows where each evicted pod may go. The node stays when that narrow set is full.

When the only allowed zone is full

With that replica evicted, the counts are 2, 2 and 1, so the global minimum is 1, in zone C. Zone A or B would give 2 (2 + 1 − 1), above a maxSkew of 1. Only zone C passes, with 1 (1 + 1 − 1).

So free room in zones A and B doesn’t help. If c1, the other node in zone C, has no room for the pod’s requests, c2 stays.

c2's replica may only go to zone C, and c1 is full Three zone boxes side by side. Zone A holds 2 pods and zone B holds 2 pods. Zone C holds node c1 with 1 pod, tagged full, the rest of it hatched, and node c2, drawn dashed and tagged draining, with 1 pod, highlighted. Dashed routes from c2's pod to zone A and to zone B end in crosses, labelled 2 + 1 − 1 = 2, above maxSkew 1. A dashed route from c2's pod to c1 ends in a cross, labelled 1 + 1 − 1 = 1, allowed, but no room. Under the zones: counts after the eviction: 2, 2, 1. c2's replica may only go to zone C, and c1 is full Zone AZone BZone Cc1fullc2draining2 + 1 − 1 = 2above maxSkew 11 + 1 − 1 = 1allowed, but no roomcounts after the eviction: 2, 2, 1
Illustration: the 6-replica Deployment with maxSkew 1 and DoNotSchedule on zone, where c2 can’t be emptied.

With an even spread and a maxSkew of 1, every evicted pod must go back to its own zone. So draining a node needs spare room in that zone. With a maxSkew of 2, the other zones pass too, at the price of a less even spread.

Taints on a draining node

  • Cordoning puts an unschedulable taint on the node.
  • The Cluster Autoscaler taints the nodes it removes.
  • Karpenter adds karpenter.sh/disrupted:NoSchedule.

The default ignores taints in the count. The draining node then stays in the skew with zero matching pods, and can pull the global minimum to 0. Kubernetes issue 106127 reports this during node upgrades.

Setting nodeTaintsPolicy to Honor leaves untolerated tainted nodes out of the count.

When a zone loses its last node

The scheduler learns domains only from nodes that exist. Once the last node in zone C goes, zone C no longer counts as a domain. Its replicas then fit the rule in zones A and B, and the workload ends up in two zones with no error.

If that isn’t acceptable, set minDomains to 3. With fewer than 3 eligible zones, the global minimum is taken as 0, so the evicted pods wait for a node in a third zone.

An autoscaler that simulates the scheduler then keeps the last zone-C node. The EKS guide suggests the same field for zones that have no nodes yet.

When a Deployment scales down

The docs warn there’s no guarantee the constraints stay satisfied when pods are removed. They point to the Descheduler to rebalance the spread.

Cluster Autoscaler not scaling down lists the other reasons a node stays. Kubernetes bin packing covers how requests decide how many nodes the pods fill.

Topology spread constraints with Karpenter: a soft rule can still hold a node

Karpenter reads spread constraints when it launches nodes. Its docs list three supported topologyKey values:

  • topology.kubernetes.io/zone
  • kubernetes.io/hostname
  • karpenter.sh/capacity-type

NodePools don’t balance zones by themselves, so zone balance comes from zonal constraints on your pods. The EKS data plane guide says EKS Auto Mode and Karpenter both launch nodes in the right Availability Zones to meet them.

On AKS, node auto-provisioning (NAP) runs Karpenter too.

Kube-scheduler only prefers a ScheduleAnyway spread. Karpenter treats preferences, spread among them, as requirements in most circumstances. It relaxes them one at a time, only when they can’t be met.

Its docs warn that a preferred spread can create more nodes than you expect. They also say consolidation “may not disrupt nodes in order to avoid violating preferences, even if kube-scheduler can fit the host pods elsewhere.”

Karpenter consolidation covers the rest of that decision.

Spread settings that still let nodes go

Spread rules help decide which nodes can be removed, so they are part of Kubernetes cost optimization.

  • Make zone spread hard only where an uneven spread is worse than a Pending pod, as the EKS guide advises. Keep node-level spread on ScheduleAnyway.
  • Leave spare room in each zone under a hard zone rule, or use a maxSkew of 2 so an evicted pod can change zones.
  • Set nodeTaintsPolicy to Honor on Kubernetes 1.33 or later, so a draining node doesn’t hold the global minimum at 0.
  • Set minDomains to your zone count only when running in fewer zones isn’t acceptable. Accept that a node per zone then stays for that workload.
  • Match each labelSelector to the pod template’s labels. For Deployments, add pod-template-hash to matchLabelKeys.

How Coral checks spread before it drains a node

Coral optimizes Kubernetes on AKS and EKS. It rebalances CPU and memory requests, and it drains spare nodes so your autoscaler can remove them. Spread rules sit inside that drain decision.

  • The check. Before draining, Coral simulates the scheduler on the live cluster. Every pod it has to move must fit elsewhere, with its affinity, taints, spread, ports and zones. If one pod has nowhere to go, the node isn’t drained.
  • The drain. Coral respects PodDisruptionBudgets and do-not-disrupt. In consolidation it also respects your autoscaler’s minimum pool size, and every pool keeps at least one node. Nodes drain one at a time, and a drain that can’t finish in time puts the node back into service.
  • The warning. The Optimization constraints page lists workload setups that make optimization risky, topology spread among them, with the fix for each. It’s advisory only. Setting the coral.zoorik.com/ignore-optimization-constraints annotation to "true" on a pod template hides a workload from that page only.

Where requests sit above real use, rebalancing them leaves more room on each node. Room in the right zone is what a hard spread rule needs before a node can go.

A floor set by minDomains or required anti-affinity comes from the rule itself, so it stays until you change the rule. For the detail, see the Coral FAQ and the guide’s consolidation section.

Sources: Kubernetes, “Pod Topology Spread Constraints” (opens in a new tab); Kubernetes, “scheduler/framework/plugins/podtopologyspread” (opens in a new tab) (scheduler source: filtering.go, common.go, plugin.go); Kubernetes, issue 106127, “Take taints/tolerations into consideration when calculating PodTopologySpread skew” (opens in a new tab); Kubernetes autoscaler, “Cluster Autoscaler FAQ” (opens in a new tab); Karpenter, “Scheduling” (opens in a new tab); Karpenter, “Disruption” (opens in a new tab); Microsoft Learn, “Zone resiliency recommendations for Azure Kubernetes Service (AKS)” (opens in a new tab); AWS, “EKS Data Plane” (opens in a new tab) (EKS best practices guide).

Find your own number

One line in your cloud console. One PDF report with your estimated saving at list price.

Estimate your saving

Talk to the builders

Thirty minutes with the people who build Coral.

Book a demo (opens in a new tab)

Or write to founders@zoorik.com