AWS FinOps Savings Plans cost optimization

AWS Savings Plans utilization dropped below 80% - diagnosing and recovering wasted commitment

Diagnose and fix AWS Savings Plans utilization drop below 80%: identify causes (scale-down, workload migration, wrong region, seasonality) and strategies to recover commitment value.

Jerzy Kopaczewski ·
Your Savings Plans Utilization Report in AWS Cost Explorer shows coverage below 80%. You are paying for a commitment you are not using - money wasted on idle reservations. With a typical Compute Savings Plan at $10/hour, every percentage point of underutilization costs ~$73/month. A drop to 60% utilization means over $2,900/month of burned budget with no return.

This runbook covers diagnosing Savings Plans utilization drops and strategies to recover the value of purchased commitments. For a detailed comparison of Savings Plans versus Reserved Instances and purchasing strategies, see AWS Savings Plans vs Reserved Instances - Discounts Without Lock-in.

Symptoms

Savings Plans Utilization Report in Cost Explorer shows a drop below the 80% threshold:

# Check Savings Plans utilization for the last 30 days
aws ce get-savings-plans-utilization \
  --time-period Start=$(date -d '30 days ago' +%Y-%m-%d),End=$(date +%Y-%m-%d) \
  --output json | jq '.Total'

# Example output indicating a problem:
# {
#   "Utilization": {
#     "TotalCommitment": "7200.00",
#     "UsedCommitment": "5040.00",
#     "UnusedCommitment": "2160.00",
#     "UtilizationPercentage": "70.0"    <-- below 80%
#   },
#   "Savings": {
#     "NetSavings": "1260.00",
#     "OnDemandCostEquivalent": "8820.00"
#   },
#   "AmortizedCommitment": {
#     "AmortizedRecurringCommitment": "0.00",
#     "AmortizedUpfrontCommitment": "7200.00",
#     "TotalAmortizedCommitment": "7200.00"
#   }
# }

# Check daily utilization trend
aws ce get-savings-plans-utilization \
  --time-period Start=$(date -d '30 days ago' +%Y-%m-%d),End=$(date +%Y-%m-%d) \
  --granularity DAILY \
  --output json | jq '.SavingsPlansUtilizationsByTime[] | {Date: .TimePeriod.Start, Utilization: .Utilization.UtilizationPercentage}'

# List purchased Savings Plans details
aws savingsplans describe-savings-plans \
  --output table \
  --query 'savingsPlans[*].{ID:savingsPlanId,Type:savingsPlanType,Commitment:commitment,State:state,Region:region,Start:start,End:end}'

In the AWS Console, the problem is visible at: Cost Explorer → Savings Plans → Utilization Report. A red or yellow utilization indicator below 80% signals wasted commitment.

Causes

Savings Plans utilization drops stem from four main scenarios:

  1. Infrastructure scaled down. Dev/test environments shut down, project ended, or the team reduced resources after optimization. Compute usage fell below the purchased commitment level. This is the most common cause in organizations that buy SP based on peak usage instead of baseline.

  2. Workload migrated to a different service not covered by SP type. EC2 Instance Savings Plans do not cover Fargate or Lambda. If the team migrated an application from EC2 to Fargate - the instance SP stops being utilized. Only Compute Savings Plans cover EC2 + Fargate + Lambda simultaneously.

  3. SP purchased for wrong region (EC2 Instance SP). EC2 Instance Savings Plans are bound to a specific region and instance family. If the workload moved to a different region (e.g., DR failover, migration closer to users) - the SP in the old region goes unused.

  4. Seasonal traffic drop below commitment baseline. The business has natural seasonal fluctuations (e-commerce after holidays, EdTech outside semester, tourism off-season). If SP was purchased at peak/average level instead of minimum - utilization drops during low-traffic periods.

Solution

Step 1: Identify which Savings Plans have low utilization

# Check utilization per Savings Plan
aws ce get-savings-plans-utilization-details \
  --time-period Start=$(date -d '30 days ago' +%Y-%m-%d),End=$(date +%Y-%m-%d) \
  --output json | jq '.SavingsPlansUtilizationDetails[] | {
    SavingsPlanArn: .SavingsPlanArn,
    Utilization: .Utilization.UtilizationPercentage,
    UnusedCommitment: .Utilization.UnusedCommitment,
    Type: .Attributes.SavingsPlansType,
    Region: .Attributes.Region,
    InstanceFamily: .Attributes.InstanceFamily
  }'

# Example output:
# {
#   "SavingsPlanArn": "arn:aws:savingsplans::123456789012:savingsplan/abcd1234",
#   "Utilization": "62.5",
#   "UnusedCommitment": "2700.00",
#   "Type": "EC2InstanceSavingsPlans",
#   "Region": "eu-west-1",
#   "InstanceFamily": "m5"
# }

# Check EC2 usage history in that region/family
aws ce get-cost-and-usage \
  --time-period Start=$(date -d '30 days ago' +%Y-%m-%d),End=$(date +%Y-%m-%d) \
  --granularity DAILY \
  --metrics "UsageQuantity" "UnblendedCost" \
  --filter '{
    "And": [
      {"Dimensions": {"Key": "SERVICE", "Values": ["Amazon Elastic Compute Cloud - Compute"]}},
      {"Dimensions": {"Key": "REGION", "Values": ["eu-west-1"]}}
    ]
  }' \
  --group-by Type=DIMENSION,Key=INSTANCE_TYPE \
  --output json | jq '.ResultsByTime[-1].Groups[] | select(.Keys[0] | startswith("m5"))'

Step 2: Diagnose the cause of usage drop

# Compare compute usage before and after the utilization drop
# Last 60 days - look for the moment of decline
aws ce get-cost-and-usage \
  --time-period Start=$(date -d '60 days ago' +%Y-%m-%d),End=$(date +%Y-%m-%d) \
  --granularity DAILY \
  --metrics "UnblendedCost" \
  --filter '{"Dimensions": {"Key": "SERVICE", "Values": ["Amazon Elastic Compute Cloud - Compute"]}}' \
  --output json | jq '[.ResultsByTime[] | {Date: .TimePeriod.Start, Cost: .Total.UnblendedCost.Amount}]'

# Check if workload migrated to Fargate/Lambda
aws ce get-cost-and-usage \
  --time-period Start=$(date -d '30 days ago' +%Y-%m-%d),End=$(date +%Y-%m-%d) \
  --granularity MONTHLY \
  --metrics "UnblendedCost" \
  --filter '{"Dimensions": {"Key": "SERVICE", "Values": [
    "Amazon Elastic Compute Cloud - Compute",
    "AWS Fargate",
    "AWS Lambda"
  ]}}' \
  --group-by Type=DIMENSION,Key=SERVICE \
  --output table

# Check changes in running instances
aws ec2 describe-instances \
  --filters "Name=instance-state-name,Values=running" \
  --query 'Reservations[*].Instances[*].{ID:InstanceId,Type:InstanceType,AZ:Placement.AvailabilityZone,Launch:LaunchTime}' \
  --output table --region eu-west-1 | grep "m5"

Step 3: Recovery strategies depending on root cause

Scenario A: Infrastructure scaled down - add workload to fill commitment

# If you have unused commitment for m5 in eu-west-1
# Move other workloads (dev, staging, batch jobs) onto m5 instances

# Check what on-demand instances are running in OTHER regions
# that could be relocated to eu-west-1
aws ec2 describe-instances \
  --filters "Name=instance-state-name,Values=running" \
  --query 'Reservations[*].Instances[*].{Type:InstanceType,AZ:Placement.AvailabilityZone,Name:Tags[?Key==`Name`].Value|[0]}' \
  --output table --region us-east-1

# Identify batch/dev workloads that can run in eu-west-1
# to fill the unused commitment

Scenario B: Migration to Fargate - commitment does not cover new service

# If you have EC2 Instance SP but workload moved to Fargate
# Options:
# 1. Wait for SP to expire (check end date)
aws savingsplans describe-savings-plans \
  --savings-plan-ids "abcd1234-..." \
  --query 'savingsPlans[0].{End:end,MonthlyCommitment:commitment}' \
  --output table

# 2. Do not renew the EC2 Instance SP
# 3. On next purchase - choose Compute SP (covers EC2+Fargate+Lambda)

# Check how much Fargate is costing - would a Compute SP cover it?
aws ce get-cost-and-usage \
  --time-period Start=$(date -d '30 days ago' +%Y-%m-%d),End=$(date +%Y-%m-%d) \
  --granularity MONTHLY \
  --metrics "UnblendedCost" \
  --filter '{"Dimensions": {"Key": "SERVICE", "Values": ["AWS Fargate"]}}' \
  --output json | jq '.ResultsByTime[0].Total.UnblendedCost'

Scenario C: Wrong region - relocate workload or accept the loss

# Check actual compute usage per region
aws ce get-cost-and-usage \
  --time-period Start=$(date -d '30 days ago' +%Y-%m-%d),End=$(date +%Y-%m-%d) \
  --granularity MONTHLY \
  --metrics "UnblendedCost" \
  --filter '{"Dimensions": {"Key": "SERVICE", "Values": ["Amazon Elastic Compute Cloud - Compute"]}}' \
  --group-by Type=DIMENSION,Key=REGION \
  --output table

# If workload is now in us-east-1 but SP is in eu-west-1:
# Option 1: move non-latency-sensitive workloads to eu-west-1
# Option 2: accept the loss until SP expires and buy new one in correct region

Scenario D: Seasonal drop - set budgets and alerts

# Create anomaly monitor for compute spend drops
aws ce create-anomaly-monitor \
  --anomaly-monitor '{
    "MonitorName": "SavingsPlansUtilizationDrop",
    "MonitorType": "CUSTOM",
    "MonitorSpecification": {
      "Dimensions": {
        "Key": "SERVICE",
        "Values": ["Amazon Elastic Compute Cloud - Compute"]
      }
    }
  }'

# For seasonal workloads: buy SP at MINIMUM baseline (not average)
# Example: if usage fluctuates $8/h - $15/h
# Buy SP at $7/h (baseline) - the rest covered on-demand
# This gives 100% SP utilization + flexibility for fluctuations

Step 4: Prevention - configure FinOps alerts

# Create a budget with utilization alert
# (requires AWS Budgets - Savings Plans Utilization budget type)

# In Console: AWS Budgets -> Create budget -> Savings Plans utilization budget
# Threshold: alert when utilization < 80%
# Notify: finops-team@company.com

# Alternative - CloudWatch metric alarm
aws cloudwatch put-metric-alarm \
  --alarm-name "SP-Utilization-Below-80" \
  --alarm-description "Savings Plans utilization dropped below 80%" \
  --metric-name "SavingsPlanUtilization" \
  --namespace "AWS/CostExplorer" \
  --statistic Average \
  --period 86400 \
  --threshold 80 \
  --comparison-operator LessThanThreshold \
  --evaluation-periods 3 \
  --alarm-actions "arn:aws:sns:eu-west-1:123456789012:finops-alerts"

Validation

After applying changes (adding workload, changing region, updating pipeline), verify utilization improvement:

# 1. Check current utilization (needs 24-48h for data propagation)
aws ce get-savings-plans-utilization \
  --time-period Start=$(date -d '7 days ago' +%Y-%m-%d),End=$(date +%Y-%m-%d) \
  --granularity DAILY \
  --output json | jq '.SavingsPlansUtilizationsByTime[-1] | {
    Date: .TimePeriod.Start,
    Utilization: .Utilization.UtilizationPercentage,
    Unused: .Utilization.UnusedCommitment
  }'
# Expected: UtilizationPercentage >= 80

# 2. Confirm net savings are positive
aws ce get-savings-plans-utilization \
  --time-period Start=$(date -d '7 days ago' +%Y-%m-%d),End=$(date +%Y-%m-%d) \
  --output json | jq '.Total.Savings.NetSavings'
# Expected: value > 0 (positive net savings)

# 3. Check coverage - how much on-demand spend is covered by SP
aws ce get-savings-plans-coverage \
  --time-period Start=$(date -d '7 days ago' +%Y-%m-%d),End=$(date +%Y-%m-%d) \
  --output json | jq '.Total.Coverage.CoveragePercentage'
# Expected: value within target coverage range (e.g., 70-85%)

# 4. Verify AWS recommendations - check if they suggest changes
aws ce get-savings-plans-purchase-recommendation \
  --savings-plans-type "COMPUTE_SP" \
  --term-in-years "ONE_YEAR" \
  --payment-option "NO_UPFRONT" \
  --lookback-period-in-days "THIRTY_DAYS" \
  --output json | jq '.SavingsPlansPurchaseRecommendation.SavingsPlansPurchaseRecommendationDetails[0]'

A Savings Plan you are not utilizing is a worse deal than on-demand - you pay the commitment and get no discount back. The key principle: buy SP at baseline (minimum guaranteed usage), not peak or average. It is better to have 100% utilization on a smaller SP with the rest on-demand, than 60% utilization on a larger SP. On your next purchase, consider Compute SP instead of EC2 Instance SP - the flexibility is worth the slightly lower discount rate.

 

Jerzy Kopaczewski

Savings Plans not delivering expected savings?

Book a free 30-minute call. We will analyse your commitments, identify utilization gaps, and propose a FinOps strategy tailored to your usage profile.