Photo by Faraaz Zuberi on Unsplash
To stop idle SageMaker notebook instances, list the ones InService with ListNotebookInstances, check when each last showed activity (the jupyter.log stream in the /aws/sagemaker/NotebookInstances log group is a good signal), and call StopNotebookInstance on those that have been quiet. Stopping ends the compute charge and keeps the ML storage volume. To prevent it recurring, attach an auto-stop lifecycle configuration.
A notebook instance is billed every hour it’s InService, whether anyone has a notebook open or not. An ml.m5.2xlarge someone started for a Friday experiment costs $336.53 a month if it’s never stopped. This example is for data platform and FinOps engineers who want to find and stop idle SageMaker notebook instances across Regions without clicking through the console.
The script reports every running notebook instance with its type, estimated uptime, last Jupyter log event, whether it has an auto-stop script and what it costs a month. It only stops instances when you pass --apply. For real-time inference endpoints, which bill the same way, use the script to find idle SageMaker endpoints.
What does an idle notebook instance cost?
As of September 2026, the AWS Price List for Amazon SageMaker (published 28 September 2026) shows these notebook instance rates in US East (N. Virginia). Other Regions differ; check the SageMaker pricing page for yours.
| Instance type | Per hour | Per month (730 hours) |
|---|---|---|
ml.t3.medium |
$0.05 | $36.50 |
ml.t3.large |
$0.10 | $73.00 |
ml.c5.xlarge |
$0.204 | $148.92 |
ml.m5.2xlarge |
$0.461 | $336.53 |
ml.g4dn.xlarge (GPU) |
$0.736 | $537.28 |
| ML storage volume | $0.14 per GB-month, billed while stopped too | |
The SageMaker API reference for StopNotebookInstance says SageMaker stops charging for the ML compute instance when you stop it and preserves the ML storage volume, so StartNotebookInstance brings your files back. Worked example: a GPU notebook on ml.g4dn.xlarge with a 100 GB volume that’s used 40 hours a month costs 730 × $0.736 + 100 × $0.14 = $551.28 left running, and 40 × $0.736 + $14.00 = $43.44 when stopped between sessions.
How can you tell a notebook instance is idle?
SageMaker doesn’t return a “last used” time for notebook instances, so the script combines three signals:
- Estimated uptime.
DescribeNotebookInstancereturnsLastModifiedTime. For an instance left running, that’s usually its last start, so the script treats it as an estimate of how long it has been up. - Jupyter activity. The SageMaker Developer Guide says Jupyter logs go to the
/aws/sagemaker/NotebookInstanceslog group in a stream namedNotebookInstanceName/jupyter.log. The script reads the stream’slastEventTimestamp. The CloudWatch Logs API reference says that value is eventually consistent and typically updates within an hour, so it’s a coarse signal, not a heartbeat. - An auto-stop script. If the instance’s lifecycle configuration mentions an idle check in its
OnStartscript, it already stops itself.
An instance that has run longer than --idle-hours (default 24) with no Jupyter log events in that time is marked STOP. Someone could still be running a long job from a terminal that writes nothing to the Jupyter log, so read the list before applying it.
What does the script do?
- Lists running notebooks
paginateListNotebookInstanceswithStatusEquals: "InService"in each Region. - Describes each one
DescribeNotebookInstancefor instance type, volume size, lifecycle configuration name andLastModifiedTime. - Reads Jupyter activity
DescribeLogStreamson/aws/sagemaker/NotebookInstanceswith the prefix<name>/jupyter.log. - Checks for auto-stop
DescribeNotebookInstanceLifecycleConfigand a search of the base64-decodedOnStartscript. - Prices and decidesMonthly compute cost from the price table, then a verdict: STOP or keep, with the reason.
- Stops on requestWith
--apply, callsStopNotebookInstancefor at most--maxSTOP rows.
Prerequisites
- Node.js 18 or later with
tsx, plus@aws-sdk/client-sagemakerand@aws-sdk/client-cloudwatch-logs. - A read-only profile for the report and a separate role for
--apply. - Somewhere to tell notebook owners before you stop their instances. Unsaved kernel state is lost on stop, though files on the volume are kept.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadNotebookInstances",
"Effect": "Allow",
"Action": [
"sagemaker:ListNotebookInstances",
"sagemaker:DescribeNotebookInstance",
"sagemaker:DescribeNotebookInstanceLifecycleConfig"
],
"Resource": "*"
},
{
"Sid": "ReadJupyterLogStreams",
"Effect": "Allow",
"Action": "logs:DescribeLogStreams",
"Resource": [
"arn:aws:logs:*:*:log-group:/aws/sagemaker/NotebookInstances",
"arn:aws:logs:*:*:log-group:/aws/sagemaker/NotebookInstances:*"
]
},
{
"Sid": "StopOnlyWithApply",
"Effect": "Allow",
"Action": "sagemaker:StopNotebookInstance",
"Resource": "arn:aws:sagemaker:*:*:notebook-instance/*"
}
]
}
Drop the last statement for a report-only role. The IAM policy generator for TypeScript SDK code lists the actions if you add calls, and the guide to AWS SDK v3 paginators explains the paginateListNotebookInstances loop.
The script to find and stop idle SageMaker notebook instances
// find-idle-sagemaker-notebook-instances.ts
// Lists SageMaker notebook instances that are InService, estimates how long each has been running, reads the
// last Jupyter log event from CloudWatch Logs as an activity signal, checks for an auto-stop lifecycle
// configuration and prices the instance. Report only by default; --apply stops rows marked STOP.
// Usage: npx tsx find-idle-sagemaker-notebook-instances.ts [--regions us-east-1,eu-west-1] [--idle-hours 24]
// [--csv notebooks.csv] [--apply --max 10]
import { writeFileSync } from "node:fs";
import {
SageMakerClient,
DescribeNotebookInstanceCommand,
DescribeNotebookInstanceLifecycleConfigCommand,
StopNotebookInstanceCommand,
paginateListNotebookInstances,
} from "@aws-sdk/client-sagemaker";
import { CloudWatchLogsClient, DescribeLogStreamsCommand } from "@aws-sdk/client-cloudwatch-logs";
const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1").split(",").map((r) => r.trim()).filter(Boolean);
const idleHours = Number(flag("--idle-hours") ?? 24);
const csvPath = flag("--csv");
const apply = args.includes("--apply");
const maxStops = Number(flag("--max") ?? 10);
// us-east-1 USD from the AWS Price List (AmazonSageMaker, usage type USE1-Notebk:<type>), published 28 September 2026.
const HOURLY: Record<string, number> = {
"ml.t3.medium": 0.05,
"ml.t3.large": 0.1,
"ml.t3.xlarge": 0.2,
"ml.c5.xlarge": 0.204,
"ml.m5.xlarge": 0.23,
"ml.m5.2xlarge": 0.461,
"ml.g4dn.xlarge": 0.736,
};
const STORAGE_PER_GB_MONTH = 0.14; // USE1-Notebk:VolumeUsage.gp2, still billed after the instance stops
const HOURS_PER_MONTH = 730;
const LOG_GROUP = "/aws/sagemaker/NotebookInstances";
interface Row {
Region: string;
Notebook: string;
Type: string;
VolumeGB: number;
RunningHours: number;
LastJupyterLog: string;
AutoStop: string;
ComputePerMonth: string;
Verdict: string;
}
const errorText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
const hoursSince = (d: Date | number | undefined): number | undefined =>
d === undefined ? undefined : Math.floor((Date.now() - new Date(d).getTime()) / 3_600_000);
/** Time of the newest event in the notebook's jupyter.log stream, if the stream exists. */
async function lastJupyterEvent(logs: CloudWatchLogsClient, name: string): Promise<number | undefined> {
try {
const out = await logs.send(new DescribeLogStreamsCommand({
logGroupName: LOG_GROUP,
logStreamNamePrefix: `${name}/jupyter.log`,
}));
const times = (out.logStreams ?? []).map((s) => s.lastEventTimestamp ?? 0).filter((t) => t > 0);
return times.length ? Math.max(...times) : undefined;
} catch (err) {
if (err instanceof Error && err.name === "ResourceNotFoundException") return undefined;
throw err;
}
}
/** "yes" when the OnStart script mentions an idle check, "no" when it doesn't, "none" without a lifecycle config. */
async function autoStop(sm: SageMakerClient, configName: string | undefined): Promise<string> {
if (!configName) return "none";
const cfg = await sm.send(new DescribeNotebookInstanceLifecycleConfigCommand({ NotebookInstanceLifecycleConfigName: configName }));
const script = (cfg.OnStart ?? []).map((h) => Buffer.from(h.Content ?? "", "base64").toString("utf8")).join("\n");
return /autostop|idle/i.test(script) ? "yes" : "no";
}
async function scanRegion(region: string): Promise<Row[]> {
const sm = new SageMakerClient({ region });
const logs = new CloudWatchLogsClient({ region });
const rows: Row[] = [];
for await (const page of paginateListNotebookInstances({ client: sm }, { StatusEquals: "InService" })) {
for (const summary of page.NotebookInstances ?? []) {
const name = summary.NotebookInstanceName ?? "";
const nb = await sm.send(new DescribeNotebookInstanceCommand({ NotebookInstanceName: name }));
const type = nb.InstanceType ?? summary.InstanceType ?? "";
// For an instance left running, LastModifiedTime is usually its last start: treat RunningHours as an estimate.
const running = hoursSince(nb.LastModifiedTime) ?? 0;
const lastLog = await lastJupyterEvent(logs, name);
const quietHours = hoursSince(lastLog);
const stopper = await autoStop(sm, nb.NotebookInstanceLifecycleConfigName);
const hourly = HOURLY[type];
let verdict: string;
if (stopper === "yes") verdict = "keep: auto-stop configured";
else if (running < idleHours) verdict = "keep: started recently";
else if (quietHours === undefined) verdict = "STOP (no Jupyter log activity found)";
else if (quietHours >= idleHours) verdict = `STOP (quiet ${quietHours} h)`;
else verdict = "keep: recent activity";
rows.push({
Region: region,
Notebook: name,
Type: type,
VolumeGB: nb.VolumeSizeInGB ?? 0,
RunningHours: running,
LastJupyterLog: lastLog ? new Date(lastLog).toISOString().slice(0, 16).replace("T", " ") : "-",
AutoStop: stopper,
ComputePerMonth: hourly !== undefined ? `$${(hourly * HOURS_PER_MONTH).toFixed(2)}` : "price not in table",
Verdict: verdict,
});
}
}
return rows;
}
async function stop(rows: Row[]): Promise<void> {
for (const r of rows.filter((x) => x.Verdict.startsWith("STOP")).slice(0, maxStops)) {
try {
await new SageMakerClient({ region: r.Region }).send(new StopNotebookInstanceCommand({ NotebookInstanceName: r.Notebook }));
console.log(`${r.Region}/${r.Notebook}: stopping (the ${r.VolumeGB} GB volume is kept)`);
} catch (err) {
console.error(`${r.Region}/${r.Notebook}: ${errorText(err)}`);
}
}
}
function toCsv(rows: Row[]): string {
const cols = Object.keys(rows[0] ?? {}) as (keyof Row)[];
const cell = (v: string | number) => `"${String(v).replace(/"/g, '""')}"`;
return [cols.join(","), ...rows.map((r) => cols.map((c) => cell(r[c])).join(","))].join("\n") + "\n";
}
async function main(): Promise<void> {
if (!Number.isFinite(idleHours) || idleHours < 1) throw new Error("--idle-hours must be 1 or more");
if (!Number.isInteger(maxStops) || maxStops < 1) throw new Error("--max must be 1 or more");
const rows: Row[] = [];
for (const region of regions) {
try {
rows.push(...(await scanRegion(region)));
} catch (err) {
console.error(`${region}: ${errorText(err)}`);
}
}
if (rows.length === 0) {
console.log(`No InService notebook instances in ${regions.join(", ")}.`);
return;
}
console.table(rows);
const idle = rows.filter((r) => r.Verdict.startsWith("STOP"));
const monthly = idle.reduce((sum, r) => sum + (HOURLY[r.Type] ?? 0) * HOURS_PER_MONTH, 0);
const storage = idle.reduce((sum, r) => sum + r.VolumeGB * STORAGE_PER_GB_MONTH, 0);
console.log(`${idle.length} of ${rows.length} running notebooks look idle: stopping them saves about ` +
`$${monthly.toFixed(2)} a month of compute; their volumes keep costing $${storage.toFixed(2)} a month (us-east-1 prices).`);
if (csvPath) {
writeFileSync(csvPath, toCsv(rows));
console.log(`Wrote ${rows.length} rows to ${csvPath}`);
}
if (apply) await stop(rows);
else if (idle.length) console.log("Dry run. Re-run with --apply to stop the rows marked STOP.");
}
main().catch((err) => {
console.error(errorText(err));
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-sagemaker @aws-sdk/client-cloudwatch-logs
npm install --save-dev tsx typescript @types/node
# Report on notebooks running for 24 hours or more with no Jupyter activity
AWS_PROFILE=readonly npx tsx find-idle-sagemaker-notebook-instances.ts --regions us-east-1,eu-west-1 --csv notebooks.csv
# Stop at most 5 idle notebooks quiet for 3 days
AWS_PROFILE=ml-platform-admin npx tsx find-idle-sagemaker-notebook-instances.ts --idle-hours 72 --apply --max 5
Sample output
┌─────────┬─────────────┬────────────────────┬──────────────────┬──────────┬──────────────┬────────────────────┬──────────┬─────────────────┬────────────────────────────────────────┐
│ (index) │ Region │ Notebook │ Type │ VolumeGB │ RunningHours │ LastJupyterLog │ AutoStop │ ComputePerMonth │ Verdict │
├─────────┼─────────────┼────────────────────┼──────────────────┼──────────┼──────────────┼────────────────────┼──────────┼─────────────────┼────────────────────────────────────────┤
│ 0 │ 'us-east-1' │ 'churn-model-dev' │ 'ml.m5.2xlarge' │ 50 │ 624 │ '2026-09-09 16:54' │ 'none' │ '$336.53' │ 'STOP (quiet 456 h)' │
│ 1 │ 'us-east-1' │ 'forecast-sandbox' │ 'ml.g4dn.xlarge' │ 100 │ 216 │ '-' │ 'none' │ '$537.28' │ 'STOP (no Jupyter log activity found)' │
│ 2 │ 'us-east-1' │ 'analytics-team' │ 'ml.t3.large' │ 20 │ 960 │ '-' │ 'yes' │ '$73.00' │ 'keep: auto-stop configured' │
│ 3 │ 'us-east-1' │ 'etl-prototype' │ 'ml.t3.medium' │ 5 │ 144 │ '2026-09-28 14:54' │ 'none' │ '$36.50' │ 'keep: recent activity' │
│ 4 │ 'us-east-1' │ 'pricing-study' │ 'ml.c5.xlarge' │ 10 │ 3 │ '-' │ 'none' │ '$148.92' │ 'keep: started recently' │
└─────────┴─────────────┴────────────────────┴──────────────────┴──────────┴──────────────┴────────────────────┴──────────┴─────────────────┴────────────────────────────────────────┘
2 of 5 running notebooks look idle: stopping them saves about $873.81 a month of compute; their volumes keep costing $21.00 a month (us-east-1 prices).
Dry run. Re-run with --apply to stop the rows marked STOP.
The run used mocked SageMaker and CloudWatch Logs responses, so names and times are illustrative. churn-model-dev has been up for 26 days with no Jupyter log events for 19; forecast-sandbox is a GPU notebook with no Jupyter log stream at all. Together they cost $873.81 a month. analytics-team has run for 40 days but has an auto-stop script, so the script leaves it alone and you may want to check why that script isn’t stopping it.
How do you stop notebooks automatically?
Stopping by script is a clean-up. The lasting fix is a lifecycle configuration that stops the instance itself. AWS publishes an auto-stop-idle lifecycle configuration sample whose on-start.sh adds a cron job that runs every 5 minutes and stops the notebook after IDLE_TIME seconds (3,600 by default) without activity. Its header notes two requirements: the instance needs internet access to download autostop.py, and the execution role needs sagemaker:StopNotebookInstance and sagemaker:DescribeNotebookInstance.
The SageMaker Developer Guide adds limits for any lifecycle script: it runs as root when the instance is created or started, must finish within 5 minutes or the start fails, and can be at most 16,384 characters. For instances without internet access, copy autostop.py into the script or an S3 bucket reachable through a VPC endpoint.
SageMaker Studio doesn’t use notebook instances. Its JupyterLab and Code Editor apps are listed by ListApps, which returns each app’s AppType and Status; this script doesn’t cover them.
Troubleshooting
- Every row says “no Jupyter log activity found”. The log group may not exist in that Region, or the notebook’s role can’t write logs. Set a retention period on it once it exists; the script to set CloudWatch Logs retention for all log groups does that.
- An auto-stop notebook shows constant activity. The sample cron job appends its output to
/var/log/jupyter.logevery 5 minutes, which can keep thejupyter.logstream from ever looking quiet. That’s why the script trusts the auto-stop check first. ComputePerMonthsays “price not in table”. Add the type’s hourly rate for your Region toHOURLY.- Access denied on
StopNotebookInstance. Check the resource ARN and any tag conditions, then see how to troubleshoot AWS IAM access denied errors. - Owners keep restarting instances. Tag notebooks with an owner and a budget; finding untagged AWS resources and creating an AWS budget alert with SDK v3 help make the cost visible.
Analytics clusters idle the same way: see the scripts to find idle EMR clusters and find idle Redshift clusters.
Ask ChatWithCloud instead
For a one-off check, ask ChatWithCloud “Which SageMaker notebook instances are running right now, what type are they, and when were they started?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the answer; how ChatWithCloud answers AWS questions from your terminal covers the details. It works in one profile and Region per session, can be wrong, and runs changes without asking first, so connect ChatWithCloud with a read-only AWS profile and stop instances with the script. Browse more clean-up scripts in the AWS practical examples collection.
Frequently asked questions
Do stopped SageMaker notebook instances cost money?
Only for storage. Stopping ends the compute charge, but the ML storage volume is kept and billed, $0.14 per GB-month in US East (N. Virginia) as of September 2026.
Do I lose my notebooks when I stop the instance?
No. Files on the ML storage volume are preserved and reattached by StartNotebookInstance. Running kernels and anything only in memory are lost.
How do I auto-stop a SageMaker notebook instance when idle?
Attach a lifecycle configuration whose OnStart script schedules an idle check, such as AWS’s auto-stop-idle sample, which stops the instance after an hour without activity by default.
Can I stop an idle notebook with the AWS CLI?
Yes: aws sagemaker stop-notebook-instance --notebook-instance-name NAME. The script adds the idle check across Regions.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud