The two filters, and when to use each
| Option | Where it runs | Use it for |
|---|---|---|
--filters (or --filter, --filter-expression, depending on the service) | Server side, by the AWS API | Cutting down what AWS sends back: state, tags, type, VPC |
--query | Client side, by the CLI (JMESPath) | Choosing fields, renaming columns, sorting, counting, conditions the API can't filter on |
Filter on the server first, then shape the result with --query. Filter names vary by service and command, so check aws <service> <command> help for the list.
aws ec2 describe-instances \
--filters "Name=instance-state-name,Values=running" "Name=tag:Env,Values=prod" \
--query "Reservations[].Instances[].{Id:InstanceId,Type:InstanceType,AZ:Placement.AvailabilityZone}" \
--output table
JMESPath syntax in one table
| Pattern | Meaning | Example |
|---|---|---|
List[] | Project and flatten a list | Reservations[].Instances[] |
List[0], List[-1], List[:5] | Index and slice | Images[:5] |
.[a,b] | Multiselect list (good for text) | Volumes[].[VolumeId,Size] |
.{A:a,B:b} | Multiselect hash (good for table) | Volumes[].{Id:VolumeId,GiB:Size} |
[?expr] | Filter | Volumes[?State=='available'] |
'text' | Raw string literal | [?VolumeType=='gp2'] |
`20` | JSON literal (numbers, booleans) | [?Size > `500`] |
| | Pipe the result into another expression | Tags[?Key=='Name'].Value | [0] |
!, &&, || | Not, and, or | [?!AssociationId] |
| Functions | length, sort_by, max_by, reverse, contains, starts_with, not_null | length(Volumes) |
Getting the Name tag
Tags are a list of Key/Value pairs, so you have to filter them:
--query "Reservations[].Instances[].{Id:InstanceId,Name:Tags[?Key=='Name']|[0].Value}"
Output formats and paging
--output json # default; query runs once on the full result
--output table # readable; best with a {} hash
--output text # tab-separated; for shell loops and grep
--output yaml
--no-cli-pager # don't open less; or: export AWS_PAGER=""
--max-items 50 # stop after N items (returns a NextToken)
--page-size 100 # smaller API pages if calls time out; still returns everything
Warning
With
--output text, the CLI runs--queryon each page separately. Expressions like[0],length()orsort_by()then return one result per page instead of one overall. Use--output jsonfor counting and sorting.
EC2 inventory
# Running instances with name, type, private IP, launch time
aws ec2 describe-instances --filters "Name=instance-state-name,Values=running" \
--query "Reservations[].Instances[].{Name:Tags[?Key=='Name']|[0].Value,Id:InstanceId,Type:InstanceType,IP:PrivateIpAddress,Launched:LaunchTime}" \
--output table
# Count running instances by type (json output for correct counts)
aws ec2 describe-instances --filters "Name=instance-state-name,Values=running" \
--query "Reservations[].Instances[].InstanceType" --output json | jq -r '.[]' | sort | uniq -c
# Stopped instances (still paying for their EBS)
aws ec2 describe-instances --filters "Name=instance-state-name,Values=stopped" \
--query "Reservations[].Instances[].[InstanceId,Tags[?Key=='Name']|[0].Value]" --output text
# Instances still allowing IMDSv1
aws ec2 describe-instances --filters "Name=metadata-options.http-tokens,Values=optional" \
--query "Reservations[].Instances[].InstanceId" --output text
# Instances missing an Owner tag
aws ec2 describe-instances \
--query "Reservations[].Instances[] | [?!not_null(Tags[?Key=='Owner'].Value|[0])].InstanceId" --output json
# Latest AMI you own
aws ec2 describe-images --owners self \
--query "max_by(Images,&CreationDate).{Id:ImageId,Name:Name,Date:CreationDate}"
EBS, snapshots and Elastic IPs (the clean-up list)
# Unattached volumes, largest first
aws ec2 describe-volumes --filters "Name=status,Values=available" \
--query "reverse(sort_by(Volumes,&Size))[].{Id:VolumeId,GiB:Size,Type:VolumeType,Created:CreateTime}" \
--output table
# gp2 volumes still to migrate to gp3
aws ec2 describe-volumes --filters "Name=volume-type,Values=gp2" \
--query "length(Volumes)"
# Your snapshots older than a date (ISO strings compare correctly)
aws ec2 describe-snapshots --owner-ids self \
--query "Snapshots[?StartTime<='2026-01-01'].[SnapshotId,VolumeId,VolumeSize,StartTime]" --output json
# Elastic IPs not associated with anything
aws ec2 describe-addresses --query "Addresses[?!AssociationId].[PublicIp,AllocationId]" --output text
Caution
These lists are where you start a clean-up, not proof that something can go. Check tags, AWS Backup and AMI references before you delete a volume or snapshot. A snapshot behind a registered AMI can't be deleted until the AMI is deregistered.
Networking
# Security groups with any rule open to 0.0.0.0/0
aws ec2 describe-security-groups --filters "Name=ip-permission.cidr,Values=0.0.0.0/0" \
--query "SecurityGroups[].{Id:GroupId,Name:GroupName,Vpc:VpcId}" --output table
# Subnets with fewer than 20 free IPs
aws ec2 describe-subnets \
--query "Subnets[?AvailableIpAddressCount < \`20\`].[SubnetId,CidrBlock,AvailabilityZone,AvailableIpAddressCount]" \
--output text
RDS, Lambda, Auto Scaling and CloudWatch
# RDS inventory
aws rds describe-db-instances \
--query "DBInstances[].{Id:DBInstanceIdentifier,Class:DBInstanceClass,Engine:Engine,Ver:EngineVersion,MultiAZ:MultiAZ,Encrypted:StorageEncrypted}" \
--output table
# RDS instances without Multi-AZ
aws rds describe-db-instances --query "DBInstances[?MultiAZ==\`false\`].DBInstanceIdentifier" --output text
# Lambda functions on a given runtime
aws lambda list-functions \
--query "Functions[?Runtime=='python3.10'].[FunctionName,LastModified]" --output text
# Lambda functions per runtime
aws lambda list-functions --query "Functions[].Runtime" --output json | jq -r '.[]' | sort | uniq -c
# Unhealthy instances in an Auto Scaling group
aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names web-asg \
--query "AutoScalingGroups[].Instances[?HealthStatus=='Unhealthy'].InstanceId" --output text
# Alarms currently firing
aws cloudwatch describe-alarms --state-value ALARM \
--query "MetricAlarms[].[AlarmName,StateUpdatedTimestamp]" --output text
IAM and S3
# Credential report: password and access key ages for every IAM user
aws iam generate-credential-report
aws iam get-credential-report --query Content --output text | base64 -d > cred-report.csv
# Roles not used recently (RoleLastUsed is only in get-role)
for r in $(aws iam list-roles --query "Roles[].RoleName" --output text); do
echo "$r $(aws iam get-role --role-name "$r" --query 'Role.RoleLastUsed.LastUsedDate' --output text)"
done
# Buckets matching a prefix
aws s3api list-buckets --query "Buckets[?starts_with(Name,'prod-')].Name" --output text
Run it across every Region
for region in $(aws ec2 describe-regions --query "Regions[].RegionName" --output text); do
n=$(aws ec2 describe-volumes --region "$region" --filters "Name=status,Values=available" \
--query "length(Volumes)" --output json)
echo "$region $n"
done
describe-regions returns only the Regions enabled for the account. For many accounts, run the loop with credentials for each account's role, or use AWS Config advanced queries from an aggregator.
PowerShell quoting
PowerShell treats the backtick as its escape character inside double-quoted strings. Raw string literals with single quotes work without changes. For JSON number literals, double the backtick:
aws ec2 describe-volumes --filters "Name=status,Values=available" `
--query "Volumes[?Size > ``100``].[VolumeId,Size]" --output text
aws ec2 describe-instances `
--query "Reservations[].Instances[].{Id:InstanceId,Name:Tags[?Key=='Name']|[0].Value}" --output table
You can also skip JMESPath and use PowerShell objects: aws ec2 describe-volumes --output json | ConvertFrom-Json.
Prevent mistakes
- Test a new
--querywith--output jsonand--max-items 5before you feed it to a loop that changes anything. - Use
--output textonly with plain projections, not with counts, sorts or[0](see the warning above). - Wrap destructive loops in
--dry-runwhere the EC2 API supports it, and log the IDs you act on. - Turn the queries you run every week into AWS Config rules or Config advanced queries so they report on their own.