AWS Resource Auditor with Python

Write a Python script that scans your AWS account for wasteful resources.

Introduction

30 Second Summary

Cloud accounts accumulate unused resources over time. Forgotten volumes, stopped instances, and old snapshots quietly add to your monthly bill without delivering any value.

In this project, you will build a Python script that connects to your AWS account and scans for resources that may be costing you money unnecessarily. You'll generate a formatted report showing exactly what's sitting idle.

What You'll Build

A command-line Python script called aws_auditor.py that uses Boto3 to audit your AWS account for unattached EBS volumes, stopped EC2 instances, and old snapshots.

By the end of this project, you'll have:

  • A working Python script that authenticates with AWS and queries your resources using Boto3 clients, paginators, and filters.
  • Hands-on experience parsing nested dictionary responses from AWS APIs using Python functions, loops, and f-strings.
  • A generated cost-savings report (audit_report.txt) showing resource IDs, sizes, and ages that you can share with your team.
  • Secret Mission: Add cost estimation that calculates potential monthly savings for each wasteful resource.

Are there any prerequisites?

You'll need an AWS account with configured credentials (access key ID and secret access key). Familiarity with the AWS console is helpful but not required.

No prior Python experience is needed. This project teaches you the fundamentals as you build.

Before We Start

Before We Start...

Install Python and Set Up Your Environment

To build a script that audits your AWS account, you need Python installed on your machine. Python is the programming language your auditor script will be written in.

In this step, you'll install Python 3.14, create a dedicated project folder with a virtual environment, and install Boto3 (the AWS SDK for Python).

In this step, get ready to:
  • Install Python 3.14.
  • Create a project folder and virtual environment.
  • Install Boto3.
Install Python 3.14

Python is not pre-installed on Windows. You'll download it from the official website and verify it works from the command line.

  • Press the Windows key to open your search bar.
  • Type PowerShell and press Enter to open a terminal.
  • Check if Python is already installed by running this command:
py --version

✔️ I see version 3.14 or higher

Python 3.14 is already installed and ready to use. Skip ahead to the next substep.

ⓧ I see an older version

You have an older version of Python installed. You need version 3.14 or higher for this project because Boto3's latest release requires Python 3.10 or above, and using the latest Python gives you the best compatibility.

  • Open python.org/downloads/release/python-3145/ in your browser.
  • Scroll down to Files and download the Windows installer (64-bit).
  • Run the installer.
  • On the first page, check Add python.exe to PATH at the bottom.
  • Click Install Now.
  • Close your current terminal and open a new PowerShell window.
  • Run py --version again to confirm you see 3.14.5.

Why a new terminal?

PATH changes made by the installer only take effect in terminals opened after the install completes. Your old terminal still uses the previous PATH.

ⓧ Command not found

Python is not installed yet. Follow these steps to install it.

  • Open python.org/downloads/release/python-3145/ in your browser.
  • Scroll down to Files and download the Windows installer (64-bit).
  • Run the installer.
  • On the first page, check Add python.exe to PATH at the bottom.
  • Click Install Now.
  • Once the installation finishes, close the installer and open a new PowerShell terminal.
  • Run py --version to confirm you see 3.14.5.

Still not recognized?

Make sure you checked Add python.exe to PATH during installation. If you missed it, re-run the installer, select Modify, and enable the PATH option.

Also confirm you opened a brand new terminal after installing. Help me fix Python not being recognized in my terminal.

Create a project folder and virtual environment

A virtual environment is an isolated space where your project's packages live. It keeps this project's dependencies separate from everything else on your machine.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir aws-auditor
cd aws-auditor
  • Confirm the folder was created by running:
ls

You should see an empty directory listing since the folder is brand new.

  • Press the Windows key to open your search bar.
  • Type the name of your code editor (e.g., VS Code) and press Enter to open it.
  • In your code editor, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the aws-auditor folder, then click Select Folder.
  • Open the integrated terminal in your code editor by pressing Ctrl+` (the backtick key, usually above Tab).

Why use the code editor's terminal?

The integrated terminal opens directly inside your project folder. You won't need to cd into the folder again each time you open a new terminal session.

  • Create a virtual environment by running this command:
py -m venv .venv

What does this command do?

py -m venv .venv tells Python to create a virtual environment in a folder called .venv. The dot prefix makes it a hidden folder, keeping your project directory tidy.

  • Activate the virtual environment by running:
.venv\Scripts\activate

You should see (.venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

Don't see (.venv) in your prompt?

If you see an error about running scripts being disabled, PowerShell's execution policy is blocking the activation script. Run this command to allow it, then try activating again:

Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned

Help me activate my Python virtual environment on Windows.

Install Boto3

Boto3 is the official AWS SDK for Python. It lets your Python code make API calls to AWS services like EC2, S3, and IAM.

  • Install Boto3 in your virtual environment by running:
pip install boto3

You should see output showing packages being downloaded and installed, ending with a success message.

Seeing a 'pip not found' error?

Make sure your virtual environment is active (you see (.venv) in your prompt). If it's not active, run .venv\Scripts\activate first.

Help me fix pip not being recognized in my terminal.

Your environment is ready. Next up, you'll configure your AWS credentials and write your first Boto3 call to prove everything is connected.

Configure AWS Credentials and Write Your First Boto3 Call

You've got Python installed, your virtual environment activated, and Boto3 ready to go.

Now it's time to connect your script to your actual AWS account. Boto3 needs your AWS credentials (access key ID and secret access key) to authenticate API calls on your behalf.

In this step, get ready to:
  • Configure your AWS credentials so Boto3 can authenticate.
  • Write your first Boto3 script that connects to AWS.
  • Run the script and confirm the connection works.
Configure AWS CLI credentials

Boto3 automatically looks for credentials in a pair of files inside a hidden .aws folder in your user profile directory.

If you already have the AWS CLI installed, you can configure credentials with a single command. Otherwise, you'll create the files manually.

✔️ I have the AWS CLI installed

  • Configure your credentials by running this command:
aws configure

The command prompts you for four values. Enter them one at a time:

  • AWS Access Key ID [None]: paste your access key ID.
  • AWS Secret Access Key [None]: paste your secret access key.
  • Default region name [None]: enter your preferred region (e.g. us-east-1).
  • Default output format [None]: press Enter to skip (the default is fine).

Where do I find my access key?

In the AWS Console, navigate to IAM, then Users, select your user, go to the Security credentials tab, and click Create access key. Copy both the access key ID and secret access key before closing the dialog.

ⓧ I don't have the AWS CLI

You can create the credentials files manually. Boto3 reads them from the same location the AWS CLI would.

  • Open File Explorer and navigate to your user profile folder (type %USERPROFILE% in the address bar).
  • Create a new folder called .aws inside your user profile folder.
  • Inside the .aws folder, create a file called credentials (no file extension) with the following content:
[default]
aws_access_key_id=your-access-key-id-here
aws_secret_access_key=your-secret-access-key-here
  • Replace your-access-key-id-here and your-secret-access-key-here with your actual AWS credentials.
  • Create a second file in the same .aws folder called config (no file extension) with the following content:
[default]
region=us-east-1
  • Replace us-east-1 with your preferred AWS region if different.

Where do I find my access key?

In the AWS Console, navigate to IAM, then Users, select your user, go to the Security credentials tab, and click Create access key. Copy both the access key ID and secret access key before closing the dialog.

Keep your credentials safe

Never commit your .aws folder or credentials to Git. These files live outside your project folder so they're safe by default.

Write a test script

With credentials configured, you can write your first Boto3 script. This script creates an EC2 client and calls the describe_volumes() API to list your EBS volumes.

  • In your code editor, create a new file called aws_auditor.py inside your aws-auditor folder.
  • Add the following code to aws_auditor.py:
import boto3

# Create an EC2 client to make API calls
client = boto3.client("ec2")

# Call describe_volumes to list all EBS volumes in your account
response = client.describe_volumes()

# Print the raw response to confirm the connection works
print(response)

What does this code do?

import boto3 loads the AWS SDK library you installed in Step 1.

boto3.client("ec2") creates a low-level client for the EC2 service. This client handles authentication automatically using the credentials you just configured.

client.describe_volumes() calls the AWS API to retrieve information about EBS volumes in your account. The response comes back as a Python dictionary.

  • Save aws_auditor.py.
Run and verify
  • Run your script by entering this command in the terminal (make sure your virtual environment is still active):
py aws_auditor.py

You should see a dictionary printed to the terminal containing a Volumes key. If your account has EBS volumes, you'll see their details. If not, you'll see an empty list.

What should I see?

A successful response looks something like this:

{'Volumes': [...], 'ResponseMetadata': {'RequestId': '...', 'HTTPStatusCode': 200, ...}}

The key thing to check is that HTTPStatusCode is 200. This confirms your credentials work and Boto3 successfully connected to AWS.

Seeing an error?

  • If you see a credentials-related error, double-check that your credentials file is in the correct location (%USERPROFILE%\.aws\credentials) and that the keys are correct.
  • If you see a region error, make sure your config file has a valid region like us-east-1.
  • If you see a module not found error for boto3, confirm your virtual environment is activated. You should see (.venv) at the start of your terminal prompt.

Help me debug my Boto3 credentials error.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

Here's the complete aws_auditor.py at this point:

import boto3

client = boto3.client("ec2")
response = client.describe_volumes()
print(response)

Your script can talk to AWS. Next up, you'll build a function that finds unattached EBS volumes that may be wasting money.

Build the Unattached Volumes Audit Function

In the previous step, you confirmed that your credentials work and Boto3 can talk to AWS. Now it's time to write a real audit function that finds resources wasting your money.

Your first target is unattached EBS volumes. These are storage disks sitting in your account with no instance using them. They still cost money every month, so finding them is the first win for your auditor.

In this step, get ready to:
  • Write a function that queries AWS for unattached EBS volumes using filters.
  • Parse the nested dictionary response from the AWS API.
  • Handle pagination so your script works even with hundreds of volumes.
Write the find_unattached_volumes function

Your current script has raw test code sitting at the top level. You will replace it with a structured approach using Python functions.

  • In your code editor, open aws_auditor.py.
  • Select all the existing code and replace it with the following:
import boto3
from datetime import datetime, timezone


def get_ec2_client():
    """Create and return an EC2 client."""
    return boto3.client("ec2")


def find_unattached_volumes(client):
    """Find EBS volumes that are not attached to any instance."""
    unattached = []
    paginator = client.get_paginator("describe_volumes")

    for page in paginator.paginate(
        Filters=[{"Name": "status", "Values": ["available"]}]
    ):
        for volume in page["Volumes"]:
            unattached.append(
                {
                    "VolumeId": volume["VolumeId"],
                    "Size": volume["Size"],
                    "VolumeType": volume["VolumeType"],
                    "CreateTime": volume["CreateTime"].strftime("%Y-%m-%d"),
                }
            )

    return unattached


client = get_ec2_client()
print(find_unattached_volumes(client))

What does this code do?

This script defines two functions and then calls them at the bottom:

  • get_ec2_client() creates a Boto3 client for the EC2 service. Wrapping it in a function makes it reusable across your script.
  • find_unattached_volumes(client) uses a paginator with a status filter set to available. This tells AWS to return only volumes that are not attached to any instance.
  • The function loops through each page of results, extracts key fields from each volume, and collects them into a list of dictionaries.
  • Save aws_auditor.py.
  • Run the script by entering this command in your terminal:
py aws_auditor.py

You should see a printed list of unattached volumes. If your account has no unattached volumes, you will see an empty list: [].

Seeing an error?

  • If you see ModuleNotFoundError: No module named 'boto3', make sure your virtual environment is activated. Run .venv\Scripts\activate and try again.
  • If you see a credentials error, double-check that your credentials file exists at %USERPROFILE%\.aws\credentials with valid keys.
  • If you see a region error, confirm your %USERPROFILE%\.aws\config file has a region value set.

Help me debug my Boto3 credentials or module error

Parse the response

The describe_volumes API returns a deeply nested dictionary. Understanding this structure is essential for working with any Boto3 call.

How does the AWS API response work?

Every Boto3 call returns a Python dictionary. For describe_volumes, the response contains a Volumes key holding a list of volume dictionaries.

Each volume dictionary contains fields like VolumeId, Size (in GiB), VolumeType, and CreateTime. Your function extracts just these four fields into a simpler dictionary, making the data easier to work with later in the report.

Handle pagination

AWS APIs return results in pages. If your account has more volumes than fit in a single response, a basic describe_volumes() call would silently miss resources.

Why use a paginator?

AWS recommends always using paginated requests. Without pagination, your script could miss volumes and give you an incomplete audit.

The paginator (client.get_paginator('describe_volumes')) handles this automatically. It fetches each page of results behind the scenes, and you iterate through them with a for loop. Your script works whether your account has 2 volumes or 200.

✔️ Awesome, I've got everything!

Great. Double check you have saved aws_auditor.py and that running it prints a list (or [] if you have no unattached volumes).

ⓧ I'd like to double check the full code

Here is the complete aws_auditor.py file at the end of this step:

import boto3
from datetime import datetime, timezone


def get_ec2_client():
    """Create and return an EC2 client."""
    return boto3.client("ec2")


def find_unattached_volumes(client):
    """Find EBS volumes that are not attached to any instance."""
    unattached = []
    paginator = client.get_paginator("describe_volumes")

    for page in paginator.paginate(
        Filters=[{"Name": "status", "Values": ["available"]}]
    ):
        for volume in page["Volumes"]:
            unattached.append(
                {
                    "VolumeId": volume["VolumeId"],
                    "Size": volume["Size"],
                    "VolumeType": volume["VolumeType"],
                    "CreateTime": volume["CreateTime"].strftime("%Y-%m-%d"),
                }
            )

    return unattached


client = get_ec2_client()
print(find_unattached_volumes(client))

Your auditor can now find unattached EBS volumes in your account. Next, you will add two more audit functions to catch stopped EC2 instances and old snapshots.

Add Stopped Instances and Old Snapshots Audits

Your auditor can already find unattached EBS volumes. Now it is time to expand the script to catch two more categories of wasteful resources.

Stopped EC2 instances still incur EBS storage charges for their attached volumes. Old EBS snapshots accumulate quietly and rack up storage costs over time. In this step, you will write two new functions that detect both of these resource types.

In this step, get ready to:
  • Write a function to find stopped EC2 instances.
  • Write a function to find EBS snapshots older than 30 days.
  • Test both new functions against your AWS account.
Write the find_stopped_instances function

The describe_instances API response nests instances inside a Reservations list. Each reservation contains its own Instances list, so you need a double loop to reach every instance.

  • In aws_auditor.py, add the following function below your find_unattached_volumes function:
def find_stopped_instances(client):
    """Find EC2 instances that are in a stopped state."""
    stopped = []
    paginator = client.get_paginator("describe_instances")

    for page in paginator.paginate(
        Filters=[{"Name": "instance-state-name", "Values": ["stopped"]}]
    ):
        for reservation in page["Reservations"]:
            for instance in reservation["Instances"]:
                name = "No Name"
                if "Tags" in instance:
                    for tag in instance["Tags"]:
                        if tag["Key"] == "Name":
                            name = tag["Value"]
                            break

                stopped.append(
                    {
                        "InstanceId": instance["InstanceId"],
                        "InstanceType": instance["InstanceType"],
                        "Name": name,
                        "LaunchTime": instance["LaunchTime"].strftime("%Y-%m-%d"),
                    }
                )

    return stopped

What does this code do?

This function uses the same paginator pattern as your volumes function, but targets describe_instances with a filter for instance-state-name = stopped.

  • The double loop (Reservations then Instances) is required because AWS groups instances by reservation in the response.
  • The Name tag search loops through the Tags list looking for a tag whose Key is "Name". If no tags exist, it defaults to "No Name".
  • LaunchTime is a datetime object from Boto3, so .strftime("%Y-%m-%d") converts it to a readable date string.
  • Save aws_auditor.py.

You will not see a change yet. The next substep adds the snapshots function, then you will test both together.

Write the find_old_snapshots function

Unlike volumes and instances, the snapshots API does not have a built-in age filter. You will fetch all of your own snapshots and filter in Python for anything older than 30 days.

  • In aws_auditor.py, add the following function below find_stopped_instances:
def find_old_snapshots(client, days_old=30):
    """Find EBS snapshots older than the specified number of days."""
    old_snapshots = []
    cutoff_date = datetime.now(timezone.utc) - __import__("datetime").timedelta(
        days=days_old
    )
    paginator = client.get_paginator("describe_snapshots")

    for page in paginator.paginate(OwnerIds=["self"]):
        for snapshot in page["Snapshots"]:
            if snapshot["StartTime"] < cutoff_date:
                old_snapshots.append(
                    {
                        "SnapshotId": snapshot["SnapshotId"],
                        "VolumeSize": snapshot["VolumeSize"],
                        "StartTime": snapshot["StartTime"].strftime("%Y-%m-%d"),
                        "Description": snapshot.get("Description", "No description"),
                    }
                )

    return old_snapshots

What does this code do?

  • OwnerIds=["self"] restricts results to snapshots you created. Without this, the API returns every public snapshot you have permissions to access, which would be thousands.
  • cutoff_date uses datetime.now(timezone.utc) to match the UTC timezone that Boto3 returns in StartTime. Using a naive datetime.now() without a timezone would cause a comparison error.
  • snapshot.get("Description", "No description") uses .get() with a default value so the script does not crash if a snapshot has no description.
  • Save aws_auditor.py.

Seeing a red underline on __import__?

Some editors flag __import__("datetime").timedelta as unusual. This is valid Python that imports the timedelta class inline without adding an extra import at the top of the file. The script will run correctly.

Help me understand why __import__ is used here instead of a top-level import.

Test each function

Now you will update the test code at the bottom of your script to call both new functions and print the results.

  • In aws_auditor.py, scroll to the bottom of the file and find this line:
print(find_unattached_volumes(client))
  • Replace that line with the following two lines, so the bottom of your file looks like this:
client = get_ec2_client()
print(find_stopped_instances(client))
print(find_old_snapshots(client))

What changed?

You replaced the old find_unattached_volumes test call with calls to your two new functions. The client = get_ec2_client() line stays the same.

  • Save aws_auditor.py.
  • Run the script by executing this command in your terminal:
py aws_auditor.py

You should see two lists printed in your terminal. Each list contains dictionaries with the resource details, or an empty list [] if your account has no stopped instances or old snapshots.

Seeing a TypeError about comparing offset-naive and offset-aware datetimes?

This means the cutoff date is missing timezone info. Make sure your code uses datetime.now(timezone.utc) and not plain datetime.now(). The timezone import at the top of your file must be present in the from datetime import datetime, timezone line.

Help me fix the timezone comparison error in my auditor script.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete aws_auditor.py file at this point:

import boto3
from datetime import datetime, timezone


def get_ec2_client():
    """Create and return an EC2 client."""
    return boto3.client("ec2")


def find_unattached_volumes(client):
    """Find EBS volumes that are not attached to any instance."""
    unattached = []
    paginator = client.get_paginator("describe_volumes")

    for page in paginator.paginate(
        Filters=[{"Name": "status", "Values": ["available"]}]
    ):
        for volume in page["Volumes"]:
            unattached.append(
                {
                    "VolumeId": volume["VolumeId"],
                    "Size": volume["Size"],
                    "VolumeType": volume["VolumeType"],
                    "CreateTime": volume["CreateTime"].strftime("%Y-%m-%d"),
                }
            )

    return unattached


def find_stopped_instances(client):
    """Find EC2 instances that are in a stopped state."""
    stopped = []
    paginator = client.get_paginator("describe_instances")

    for page in paginator.paginate(
        Filters=[{"Name": "instance-state-name", "Values": ["stopped"]}]
    ):
        for reservation in page["Reservations"]:
            for instance in reservation["Instances"]:
                name = "No Name"
                if "Tags" in instance:
                    for tag in instance["Tags"]:
                        if tag["Key"] == "Name":
                            name = tag["Value"]
                            break

                stopped.append(
                    {
                        "InstanceId": instance["InstanceId"],
                        "InstanceType": instance["InstanceType"],
                        "Name": name,
                        "LaunchTime": instance["LaunchTime"].strftime("%Y-%m-%d"),
                    }
                )

    return stopped


def find_old_snapshots(client, days_old=30):
    """Find EBS snapshots older than the specified number of days."""
    old_snapshots = []
    cutoff_date = datetime.now(timezone.utc) - __import__("datetime").timedelta(
        days=days_old
    )
    paginator = client.get_paginator("describe_snapshots")

    for page in paginator.paginate(OwnerIds=["self"]):
        for snapshot in page["Snapshots"]:
            if snapshot["StartTime"] < cutoff_date:
                old_snapshots.append(
                    {
                        "SnapshotId": snapshot["SnapshotId"],
                        "VolumeSize": snapshot["VolumeSize"],
                        "StartTime": snapshot["StartTime"].strftime("%Y-%m-%d"),
                        "Description": snapshot.get("Description", "No description"),
                    }
                )

    return old_snapshots


client = get_ec2_client()
print(find_stopped_instances(client))
print(find_old_snapshots(client))

Your auditor can now detect three types of wasteful resources. Next up, you will combine all three results into a formatted report that is easy to read and share.

Generate the Audit Report

Your three audit functions are scanning AWS for wasteful resources. Right now, though, they just dump raw Python dictionaries to the terminal.

In this step, you'll format those results into a clean, readable report and save it to a file. This is the kind of output you'd email to your team or attach to a cost-review ticket.

In this step, get ready to:
  • Write a report-formatting function that organizes audit results into labeled sections.
  • Save the report to a text file using Python's file I/O.
  • Wire all functions together in a clean entry point and run the complete script.
Write the generate_report function

Each of your audit functions returns a list of dictionaries. The generate_report function takes all three lists and formats them into a single text report with headers, resource details, and a summary count.

  • In aws_auditor.py, find the test code at the bottom of your file (the three lines starting with client = get_ec2_client()).
  • Delete those three test lines. You'll replace them with proper functions shortly.
  • Add the following generate_report function below the find_old_snapshots function:
def generate_report(unattached_volumes, stopped_instances, old_snapshots):
    """Generate a formatted audit report."""
    lines = []
    # Report header with title and timestamp
    lines.append("=" * 60)
    lines.append("       AWS RESOURCE AUDIT REPORT")
    lines.append(f"       Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    lines.append("=" * 60)

    # Unattached Volumes Section
    lines.append("")
    lines.append(f"--- Unattached EBS Volumes ({len(unattached_volumes)} found) ---")
    if unattached_volumes:
        for vol in unattached_volumes:
            lines.append(
                f"  {vol['VolumeId']}  |  {vol['Size']} GiB  |  "
                f"{vol['VolumeType']}  |  Created: {vol['CreateTime']}"
            )
    else:
        lines.append("  None found. Great job!")

    # Stopped Instances Section
    lines.append("")
    lines.append(f"--- Stopped EC2 Instances ({len(stopped_instances)} found) ---")
    if stopped_instances:
        for inst in stopped_instances:
            lines.append(
                f"  {inst['InstanceId']}  |  {inst['InstanceType']}  |  "
                f"Name: {inst['Name']}  |  Launched: {inst['LaunchTime']}"
            )
    else:
        lines.append("  None found. Great job!")

    # Old Snapshots Section
    lines.append("")
    lines.append(f"--- Old EBS Snapshots (>{30} days) ({len(old_snapshots)} found) ---")
    if old_snapshots:
        for snap in old_snapshots:
            lines.append(
                f"  {snap['SnapshotId']}  |  {snap['VolumeSize']} GiB  |  "
                f"Created: {snap['StartTime']}  |  {snap['Description'][:40]}"
            )
    else:
        lines.append("  None found. Great job!")

    # Summary
    lines.append("")
    lines.append("=" * 60)
    lines.append("  SUMMARY")
    lines.append(f"  Unattached volumes: {len(unattached_volumes)}")
    lines.append(f"  Stopped instances:  {len(stopped_instances)}")
    lines.append(f"  Old snapshots:      {len(old_snapshots)}")
    lines.append("=" * 60)

    # Join all lines into a single string
    report = "\n".join(lines)
    return report

What does this code do?

This function builds the report line by line using a lines list, then joins them together at the end with "\n".join(lines).

  • The header prints a title and a timestamp so you know when the audit ran.
  • Each resource section shows a count in the heading and loops through results using f-strings to format details into columns.
  • If a section has zero results, it prints "None found. Great job!" instead of an empty block.
  • The summary at the bottom gives a quick count of all findings.
  • Save aws_auditor.py.
  • You won't see a change yet. The next chunk wires this function into a main() entry point that actually calls it.

Seeing a syntax error?

  • Check that you deleted the old test lines at the bottom. Leftover code outside a function can cause indentation or name errors.
  • Make sure the f-strings inside lines.append() each start with f". Missing the f prefix means the curly braces won't be evaluated.

Help me fix a syntax error in my generate_report function.

Add file output

Right now, generate_report returns a string. You need a way to save that string to a file so you can share it or reference it later.

What is a with statement?

Python's with open("filename", "w") as f: pattern opens a file, lets you write to it, then automatically closes it when the block ends. The "w" mode means "write" (create the file fresh each time).

This is safer than manually opening and closing files because it handles cleanup even if your code crashes partway through.

You'll use this pattern inside the main() function in the next substep to write your report to audit_report.txt.

Wire it all together in main()

The main() function is the orchestrator. It creates the client, calls all three audit functions, generates the report, prints it to the terminal, and writes it to a file.

  • Add the following main() function and entry-point guard below your generate_report function:
def main():
    """Run the full AWS resource audit."""
    print("Starting AWS Resource Audit...")
    print("")

    # Create the EC2 client
    client = get_ec2_client()

    # Run each audit function
    print("Scanning for unattached EBS volumes...")
    unattached_volumes = find_unattached_volumes(client)

    print("Scanning for stopped EC2 instances...")
    stopped_instances = find_stopped_instances(client)

    print("Scanning for old EBS snapshots...")
    old_snapshots = find_old_snapshots(client)

    # Generate and print the report
    print("")
    report = generate_report(unattached_volumes, stopped_instances, old_snapshots)
    print(report)

    # Write the report to a file
    with open("audit_report.txt", "w") as f:
        f.write(report)

    print("")
    print("Report saved to audit_report.txt")


if __name__ == "__main__":
    main()

What does this code do?

  • main() calls each audit function in sequence, printing progress messages so you know what's happening.
  • It passes all three result lists to generate_report, then prints the formatted report to the terminal.
  • The with open("audit_report.txt", "w") as f: block writes the same report to a file so you have a permanent copy.
  • The if __name__ == "__main__": guard ensures main() only runs when you execute the script directly, not when you import it from another file.
  • Save aws_auditor.py.
  • Run the complete auditor script by running this command:
py aws_auditor.py
  • You should see progress messages ("Scanning for unattached EBS volumes...") followed by a formatted report with sections for each resource type and a summary at the bottom.
  • Confirm the report file was created by running:
ls
  • You should see audit_report.txt listed alongside aws_auditor.py in the output.

Script crashing or no report file?

  • If you see a NameError for generate_report, make sure the function is defined above main() in the file. Python reads top to bottom.
  • If the script runs but audit_report.txt is missing, check that you included the if __name__ == "__main__": guard. Without it, main() never gets called.
  • If you see a credentials error, make sure your virtual environment is still activated (you should see (.venv) in your terminal prompt).

Help me debug my AWS auditor script.

What if I see "None found" for everything?

That's perfectly fine. It means your AWS account is clean. The script is still working correctly. It checked for wasteful resources and found nothing to report.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

Here's the complete aws_auditor.py at this point:

import boto3
from datetime import datetime, timezone


def get_ec2_client():
    """Create and return an EC2 client."""
    return boto3.client("ec2")


def find_unattached_volumes(client):
    """Find EBS volumes that are not attached to any instance."""
    unattached = []
    paginator = client.get_paginator("describe_volumes")

    for page in paginator.paginate(
        Filters=[{"Name": "status", "Values": ["available"]}]
    ):
        for volume in page["Volumes"]:
            unattached.append(
                {
                    "VolumeId": volume["VolumeId"],
                    "Size": volume["Size"],
                    "VolumeType": volume["VolumeType"],
                    "CreateTime": volume["CreateTime"].strftime("%Y-%m-%d"),
                }
            )

    return unattached


def find_stopped_instances(client):
    """Find EC2 instances that are in a stopped state."""
    stopped = []
    paginator = client.get_paginator("describe_instances")

    for page in paginator.paginate(
        Filters=[{"Name": "instance-state-name", "Values": ["stopped"]}]
    ):
        for reservation in page["Reservations"]:
            for instance in reservation["Instances"]:
                name = "No Name"
                if "Tags" in instance:
                    for tag in instance["Tags"]:
                        if tag["Key"] == "Name":
                            name = tag["Value"]
                            break

                stopped.append(
                    {
                        "InstanceId": instance["InstanceId"],
                        "InstanceType": instance["InstanceType"],
                        "Name": name,
                        "LaunchTime": instance["LaunchTime"].strftime("%Y-%m-%d"),
                    }
                )

    return stopped


def find_old_snapshots(client, days_old=30):
    """Find EBS snapshots older than the specified number of days."""
    old_snapshots = []
    cutoff_date = datetime.now(timezone.utc) - __import__("datetime").timedelta(
        days=days_old
    )
    paginator = client.get_paginator("describe_snapshots")

    for page in paginator.paginate(OwnerIds=["self"]):
        for snapshot in page["Snapshots"]:
            if snapshot["StartTime"] < cutoff_date:
                old_snapshots.append(
                    {
                        "SnapshotId": snapshot["SnapshotId"],
                        "VolumeSize": snapshot["VolumeSize"],
                        "StartTime": snapshot["StartTime"].strftime("%Y-%m-%d"),
                        "Description": snapshot.get("Description", "No description"),
                    }
                )

    return old_snapshots


def generate_report(unattached_volumes, stopped_instances, old_snapshots):
    """Generate a formatted audit report."""
    lines = []
    lines.append("=" * 60)
    lines.append("       AWS RESOURCE AUDIT REPORT")
    lines.append(f"       Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    lines.append("=" * 60)

    lines.append("")
    lines.append(f"--- Unattached EBS Volumes ({len(unattached_volumes)} found) ---")
    if unattached_volumes:
        for vol in unattached_volumes:
            lines.append(
                f"  {vol['VolumeId']}  |  {vol['Size']} GiB  |  "
                f"{vol['VolumeType']}  |  Created: {vol['CreateTime']}"
            )
    else:
        lines.append("  None found. Great job!")

    lines.append("")
    lines.append(f"--- Stopped EC2 Instances ({len(stopped_instances)} found) ---")
    if stopped_instances:
        for inst in stopped_instances:
            lines.append(
                f"  {inst['InstanceId']}  |  {inst['InstanceType']}  |  "
                f"Name: {inst['Name']}  |  Launched: {inst['LaunchTime']}"
            )
    else:
        lines.append("  None found. Great job!")

    lines.append("")
    lines.append(f"--- Old EBS Snapshots (>{30} days) ({len(old_snapshots)} found) ---")
    if old_snapshots:
        for snap in old_snapshots:
            lines.append(
                f"  {snap['SnapshotId']}  |  {snap['VolumeSize']} GiB  |  "
                f"Created: {snap['StartTime']}  |  {snap['Description'][:40]}"
            )
    else:
        lines.append("  None found. Great job!")

    lines.append("")
    lines.append("=" * 60)
    lines.append("  SUMMARY")
    lines.append(f"  Unattached volumes: {len(unattached_volumes)}")
    lines.append(f"  Stopped instances:  {len(stopped_instances)}")
    lines.append(f"  Old snapshots:      {len(old_snapshots)}")
    lines.append("=" * 60)

    report = "\n".join(lines)
    return report


def main():
    """Run the full AWS resource audit."""
    print("Starting AWS Resource Audit...")
    print("")

    client = get_ec2_client()

    print("Scanning for unattached EBS volumes...")
    unattached_volumes = find_unattached_volumes(client)

    print("Scanning for stopped EC2 instances...")
    stopped_instances = find_stopped_instances(client)

    print("Scanning for old EBS snapshots...")
    old_snapshots = find_old_snapshots(client)

    print("")
    report = generate_report(unattached_volumes, stopped_instances, old_snapshots)
    print(report)

    with open("audit_report.txt", "w") as f:
        f.write(report)

    print("")
    print("Report saved to audit_report.txt")


if __name__ == "__main__":
    main()

Secret mission

Add Cost Estimation

Your audit report finds wasteful resources, but how much money are they actually costing? In this mission, you'll add dollar estimates to every resource line and calculate the total potential monthly savings your team could reclaim.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and only makes read-only API calls to AWS, so there are no ongoing costs. Decide whether to keep your files for future use, pause your environment, or delete everything.

Resources you used:

  • aws-auditor project folder (contains aws_auditor.py, audit_report.txt, and the .venv virtual environment).
  • AWS credentials files (%USERPROFILE%\.aws\credentials and %USERPROFILE%\.aws\config).
  • Python 3.14 (installed on your system).

Keep everything running

No action needed. Choose this if you plan to keep developing your auditor script or want to reuse your Python environment for other projects.

  • Your aws-auditor folder stays on your Desktop.
  • Your AWS credentials remain configured for future Boto3 scripts.
  • Python 3.14 stays installed and available for other projects.

Pause - I'll come back to this later

Deactivate your virtual environment to free up your terminal session. All files stay on disk for when you return.

  • Deactivate the virtual environment by running this in your terminal:
deactivate
  • When you come back, reactivate it by navigating to your project folder and running .venv\Scripts\activate.

Delete - I don't want to use this again

Remove all project files and optionally uninstall Python if you no longer need it.

  • Deactivate your virtual environment first (if still active):
deactivate
  • Delete the project folder by running this command on Windows:
rmdir /s /q %USERPROFILE%\Desktop\aws-auditor
  • On macOS or Linux, run this instead:
rm -rf ~/Desktop/aws-auditor
  • To remove your AWS credentials files, delete the .aws folder. Only do this if you created these credentials specifically for this project and don't use them elsewhere:
rmdir /s /q %USERPROFILE%\.aws
  • To uninstall Python 3.14 on Windows, open Settings, select Apps, then Installed apps.
  • Search for Python, click the three-dot menu next to it, and select Uninstall.

Nice Work!

Nice Work!

You just built a real-world cloud engineering tool with Python. Your aws_auditor.py script connects to your AWS account, scans for wasteful resources, and generates a cost-savings report you can share with your team.

You've learned how to:

  • Write a Python script that authenticates with AWS using Boto3 clients, paginators, and filters to query EC2 resources.
  • Parse nested dictionary responses from AWS APIs to identify wasteful resources like unattached EBS volumes, stopped EC2 instances, and old snapshots.
  • Generate a formatted text report using Python's file I/O, combining all audit results into a shareable audit_report.txt document.
  • Secret Mission: Calculated estimated monthly costs for each wasteful resource using EBS pricing data and added a total potential savings summary to your report.

Ready to quiz yourself?