Photo by Adolfo Félix on Unsplash
To find unused Amazon WorkSpaces, list them with DescribeWorkspaces, then call DescribeWorkspacesConnectionStatus (up to 25 IDs per call) and read LastKnownUserConnectionTimestamp. A WorkSpace whose user hasn’t connected in 30 days, or never, is unused. If it runs in ALWAYS_ON mode you pay the full monthly fee for it; switching it to AUTO_STOP cuts that to a small base fee.
Virtual desktops outlive the people they were made for. A contractor finishes, a pilot group moves on, a new hire is provisioned twice, and each WorkSpace keeps billing. With AlwaysOn billing the charge is the same whether the user logs in every day or never again, and the WorkSpaces console won’t flag it.
This example is for IT and cloud admins who want a list they can take to managers. The script finds unused Amazon WorkSpaces in every Region you pass, shows running mode, compute type, the last connection and what switching to AutoStop saves. With --apply it switches unused AlwaysOn WorkSpaces to AutoStop. It never deletes a WorkSpace: that removes the user’s data, so it stays a human decision.
What does an unused WorkSpace cost?
As of September 2026, the AWS Price List shows these rates for WorkSpaces Personal in US East (N. Virginia), Windows with the license included and each bundle’s default storage. Linux, bring-your-own-license and larger volumes are priced differently; check the Amazon WorkSpaces pricing page for your bundle and Region.
| Bundle | AlwaysOn per month | AutoStop base per month | AutoStop per hour | Break-even hours |
|---|---|---|---|---|
| Value (1 vCPU, 2 GB) | $25 | $7.25 | $0.22 | 81 |
| Standard (2 vCPU, 4 GB) | $35 | $9.75 | $0.30 | 84 |
| Performance (2 vCPU, 8 GB) | $50 | $13 | $0.47 | 79 |
| Power (4 vCPU, 16 GB) | $78 | $19 | $0.68 | 87 |
| PowerPro (8 vCPU, 32 GB) | $140 | $19 | $1.53 | 79 |
Break-even is the monthly difference divided by the hourly rate. For Standard, ($35 − $9.75) ÷ $0.30 = 84 hours: below that AutoStop is cheaper, above it AlwaysOn is. An unused Standard WorkSpace in AlwaysOn costs $35 a month; in AutoStop it costs the $9.75 base fee, a $25.25 saving. Twenty forgotten Standard desktops are $505 a month, or $6,060 a year. Only deleting the WorkSpace stops the base fee: AWS’s WorkSpaces FAQ confirms you pay it even in a month with no use.
How does the script decide a WorkSpace is unused?
LastKnownUserConnectionTimestampfromDescribeWorkspacesConnectionStatusis the time of the last known user connection. Older than--days(default 30) is UNUSED; missing is NEVER CONNECTED.ConnectionStateisCONNECTED,DISCONNECTEDorUNKNOWN, and is unknown whenever the WorkSpace is stopped, so the script doesn’t rely on it.RunningModeinWorkspacePropertiesisALWAYS_ON,AUTO_STOPorMANUAL.MANUALis only for WorkSpaces Core, so it’s reported but never changed.
A NEVER CONNECTED WorkSpace can be one provisioned yesterday for someone who starts on Monday, so treat that label as a question for the manager, not a verdict.
What does the script do?
- Lists WorkSpaces
paginateDescribeWorkspacesreturns each WorkSpace’s ID, user, state, compute type and running mode. - Reads last connections
DescribeWorkspacesConnectionStatusaccepts at most 25 IDs and has no SDK paginator, so the script sends batches of 25 and followsNextTokenby hand. - Prices the savingAlwaysOn monthly minus the AutoStop base fee for unused AlwaysOn WorkSpaces; the base fee alone for unused AutoStop ones, which only deletion removes.
- Switches only on requestWith
--apply,ModifyWorkspacePropertiessetsRunningMode: "AUTO_STOP"with a 60-minute stop time for unused AlwaysOn WorkSpaces in theAVAILABLEorSTOPPEDstate.
Prerequisites
- Node.js 18 or later with
tsx, and@aws-sdk/client-workspaces. - An AWS profile the SDK can resolve, as covered in AWS SDK v3 credential providers: fromIni, fromSSO and assume role.
- The Regions where your WorkSpaces directories live. To see how much WorkSpaces costs you first, run the script to get last month’s AWS cost broken down by service.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadWorkSpaces",
"Effect": "Allow",
"Action": [
"workspaces:DescribeWorkspaces",
"workspaces:DescribeWorkspacesConnectionStatus"
],
"Resource": "*"
},
{
"Sid": "SwitchRunningModeWithApply",
"Effect": "Allow",
"Action": "workspaces:ModifyWorkspaceProperties",
"Resource": "arn:aws:workspaces:*:123456789012:workspace/*"
}
]
}
Replace the account ID and remove the second statement for a report-only role. Scripts you adapt can go through the IAM policy generator for TypeScript code, which lists the actions the code calls.
The script to find unused Amazon WorkSpaces
// find-unused-amazon-workspaces.ts
// Lists Amazon WorkSpaces Personal desktops whose user hasn't connected for N days (or ever), with running mode,
// compute type and what switching an unused AlwaysOn WorkSpace to AutoStop would save.
// Report only by default. --apply switches unused ALWAYS_ON WorkSpaces to AUTO_STOP. It never terminates anything.
// Usage: npx tsx find-unused-amazon-workspaces.ts [--regions us-east-1,eu-west-1] [--days 30] [--csv workspaces.csv] [--apply]
import { writeFileSync } from "node:fs";
import {
DescribeWorkspacesConnectionStatusCommand,
ModifyWorkspacePropertiesCommand,
WorkSpacesClient,
paginateDescribeWorkspaces,
type Workspace,
} from "@aws-sdk/client-workspaces";
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 days = Number(flag("--days") ?? 30);
const csvPath = flag("--csv");
const apply = args.includes("--apply");
const DAY_MS = 86_400_000;
// us-east-1, Windows with license included, default bundle storage. AWS Price List, September 2026.
// [AlwaysOn per month, AutoStop per month, AutoStop per hour]
const PRICE: Record<string, [number, number, number]> = {
VALUE: [25, 7.25, 0.22],
STANDARD: [35, 9.75, 0.3],
PERFORMANCE: [50, 13, 0.47],
POWER: [78, 19, 0.68],
POWERPRO: [140, 19, 1.53],
};
interface Row {
Region: string;
WorkspaceId: string;
User: string;
Compute: string;
RunningMode: string;
State: string;
LastConnected: string;
IdleDays: number | string;
SavingPerMonth: string;
Verdict: string;
Action: string;
}
/** LastKnownUserConnectionTimestamp per WorkSpace. The API takes at most 25 IDs per call and has no paginator. */
async function lastConnections(ws: WorkSpacesClient, ids: string[]): Promise<Map<string, Date | undefined>> {
const result = new Map<string, Date | undefined>();
for (let i = 0; i < ids.length; i += 25) {
let NextToken: string | undefined;
do {
const res = await ws.send(new DescribeWorkspacesConnectionStatusCommand({ WorkspaceIds: ids.slice(i, i + 25), NextToken }));
for (const s of res.WorkspacesConnectionStatus ?? []) {
if (s.WorkspaceId) result.set(s.WorkspaceId, s.LastKnownUserConnectionTimestamp);
}
NextToken = res.NextToken;
} while (NextToken);
}
return result;
}
async function scanRegion(region: string): Promise<Row[]> {
const ws = new WorkSpacesClient({ region });
const workspaces: Workspace[] = [];
for await (const page of paginateDescribeWorkspaces({ client: ws }, {})) {
workspaces.push(...(page.Workspaces ?? []));
}
if (workspaces.length === 0) return [];
const connections = await lastConnections(ws, workspaces.map((w) => w.WorkspaceId ?? "").filter(Boolean));
const now = Date.now();
const rows: Row[] = [];
for (const w of workspaces) {
const id = w.WorkspaceId ?? "";
const mode = w.WorkspaceProperties?.RunningMode ?? "UNKNOWN";
const compute = w.WorkspaceProperties?.ComputeTypeName ?? "UNKNOWN";
const last = connections.get(id);
const idleDays = last ? Math.floor((now - last.getTime()) / DAY_MS) : undefined;
const unused = idleDays === undefined || idleDays >= days;
const price = PRICE[compute];
let verdict = "in use";
let saving = "-";
if (unused && mode === "ALWAYS_ON") {
verdict = idleDays === undefined ? "NEVER CONNECTED: AlwaysOn" : "UNUSED: AlwaysOn";
saving = price ? `$${(price[0] - price[1]).toFixed(2)}` : "check pricing";
} else if (unused && mode === "AUTO_STOP") {
verdict = idleDays === undefined ? "NEVER CONNECTED: AutoStop" : "UNUSED: AutoStop (base fee only)";
saving = price ? `$${price[1].toFixed(2)} if removed` : "check pricing";
} else if (unused) {
verdict = `UNUSED: ${mode}`;
}
const switchable = unused && mode === "ALWAYS_ON" && (w.State === "AVAILABLE" || w.State === "STOPPED");
let action = switchable ? "would switch to AUTO_STOP (--apply)" : "-";
if (apply && switchable) {
try {
await ws.send(new ModifyWorkspacePropertiesCommand({
WorkspaceId: id,
WorkspaceProperties: { RunningMode: "AUTO_STOP", RunningModeAutoStopTimeoutInMinutes: 60 },
}));
action = "switched to AUTO_STOP";
} catch (err) {
action = `failed: ${err instanceof Error ? err.name : String(err)}`;
}
}
rows.push({
Region: region,
WorkspaceId: id,
User: w.UserName ?? "",
Compute: compute,
RunningMode: mode,
State: w.State ?? "",
LastConnected: last ? last.toISOString().slice(0, 10) : "never",
IdleDays: idleDays ?? "-",
SavingPerMonth: saving,
Verdict: verdict,
Action: action,
});
}
return rows;
}
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> {
const rows: Row[] = [];
for (const region of regions) {
try {
rows.push(...(await scanRegion(region)));
} catch (err) {
console.error(`${region}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
}
}
if (rows.length === 0) {
console.log(`No WorkSpaces in ${regions.join(", ")}.`);
return;
}
console.table(rows);
const alwaysOnUnused = rows.filter((r) => r.Verdict.includes("AlwaysOn"));
const saving = alwaysOnUnused.reduce((sum, r) => sum + Number(/\$([\d.]+)/.exec(r.SavingPerMonth)?.[1] ?? 0), 0);
console.log(`${rows.filter((r) => r.Verdict !== "in use").length} of ${rows.length} WorkSpaces unused for ${days}+ days. ` +
`Switching the ${alwaysOnUnused.length} AlwaysOn ones to AutoStop saves about $${saving.toFixed(2)} a month (us-east-1 prices, from next month).`);
if (csvPath) {
writeFileSync(csvPath, toCsv(rows));
console.log(`Wrote ${rows.length} rows to ${csvPath}`);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-workspaces
npm install --save-dev tsx typescript @types/node
# Report only, with a CSV for managers
AWS_PROFILE=readonly npx tsx find-unused-amazon-workspaces.ts --regions us-east-1,eu-west-1 --csv workspaces.csv
# Switch AlwaysOn WorkSpaces unused for 60+ days to AutoStop
AWS_PROFILE=workspaces-admin npx tsx find-unused-amazon-workspaces.ts --regions us-east-1 --days 60 --apply
Sample output
┌─────────┬─────────────┬────────────────┬─────────────────┬───────────────┬─────────────┬─────────────┬───────────────┬──────────┬────────────────────┬────────────────────────────────────┬───────────────────────────────────────┐
│ (index) │ Region │ WorkspaceId │ User │ Compute │ RunningMode │ State │ LastConnected │ IdleDays │ SavingPerMonth │ Verdict │ Action │
├─────────┼─────────────┼────────────────┼─────────────────┼───────────────┼─────────────┼─────────────┼───────────────┼──────────┼────────────────────┼────────────────────────────────────┼───────────────────────────────────────┤
│ 0 │ 'us-east-1' │ 'ws-4k2mvy9pq' │ 'a.fernandes' │ 'STANDARD' │ 'ALWAYS_ON' │ 'AVAILABLE' │ '2026-09-28' │ 0 │ '-' │ 'in use' │ '-' │
│ 1 │ 'us-east-1' │ 'ws-7h3tq81zc' │ 'j.okafor' │ 'PERFORMANCE' │ 'ALWAYS_ON' │ 'AVAILABLE' │ '2026-07-26' │ 64 │ '$37.00' │ 'UNUSED: AlwaysOn' │ 'would switch to AUTO_STOP (--apply)' │
│ 2 │ 'us-east-1' │ 'ws-9b1xr55kd' │ 'contractor-02' │ 'STANDARD' │ 'ALWAYS_ON' │ 'AVAILABLE' │ '2026-05-09' │ 142 │ '$25.25' │ 'UNUSED: AlwaysOn' │ 'would switch to AUTO_STOP (--apply)' │
│ 3 │ 'us-east-1' │ 'ws-2p8wn63ls' │ 'm.chen' │ 'POWER' │ 'ALWAYS_ON' │ 'AVAILABLE' │ 'never' │ '-' │ '$59.00' │ 'NEVER CONNECTED: AlwaysOn' │ 'would switch to AUTO_STOP (--apply)' │
│ 4 │ 'us-east-1' │ 'ws-5r6jd02qa' │ 's.patel' │ 'STANDARD' │ 'AUTO_STOP' │ 'STOPPED' │ '2026-06-24' │ 96 │ '$9.75 if removed' │ 'UNUSED: AutoStop (base fee only)' │ '-' │
│ 5 │ 'us-east-1' │ 'ws-8c4hz37vf' │ 'l.nowak' │ 'VALUE' │ 'AUTO_STOP' │ 'STOPPED' │ '2026-09-25' │ 3 │ '-' │ 'in use' │ '-' │
└─────────┴─────────────┴────────────────┴─────────────────┴───────────────┴─────────────┴─────────────┴───────────────┴──────────┴────────────────────┴────────────────────────────────────┴───────────────────────────────────────┘
4 of 6 WorkSpaces unused for 30+ days. Switching the 3 AlwaysOn ones to AutoStop saves about $121.25 a month (us-east-1 prices, from next month).
IDs, users and dates are illustrative. contractor-02 hasn’t connected in 142 days and still pays the full AlwaysOn rate. m.chen has a Power WorkSpace that has never been used: $78 a month for nothing, or $59 less on AutoStop. s.patel is already on AutoStop, so only the $9.75 base fee remains; removing it is the manager’s call.
What should you check before switching or deleting?
- Timing. Per the WorkSpaces FAQ, switching from monthly to hourly billing takes effect the following month, because the current month is already paid. Switching mid-month saves nothing until then.
- Hibernation. AutoStop WorkSpaces normally save their state when they stop, but GPU-enabled bundles, GeneralPurpose.4xlarge and GeneralPurpose.8xlarge don’t hibernate, and neither do some newer Windows versions with nested virtualization. Users lose unsaved work, so tell them before you switch.
- Leavers. An unused WorkSpace often belongs to someone who left. Offboard the rest of their access too; the scripts to find IAM access keys older than 90 days or never used and find unused IAM roles with RoleLastUsed cover the AWS side.
- Deleting. Only deleting a WorkSpace ends the AutoStop base fee, and it deletes the user’s volumes. Do it by hand after the owner confirms, never from a script.
Unused desktops are one line on a bigger idle-resource list. The scripts to detect and stop underutilized EC2 instances by CPU and find EC2 instances stopped for weeks and still costing you cover the servers. For data science teams, the equivalent is a notebook instance nobody has opened in days; the script to find and stop idle SageMaker notebook instances does the same job for SageMaker.
Troubleshooting
- AutoStop WorkSpaces that never stop. They stop only after the user disconnects. A laptop that is locked or asleep with the client still running may not count as disconnected, and third-party clients may not report disconnection at all. Ask users to disconnect or quit the client at the end of the day.
OperationInProgressExceptionon--apply. The WorkSpace’s properties are already being modified. Run the script again later.InvalidResourceStateException. The API reference describes it as the resource’s state not being valid for the operation. The script only triesAVAILABLEandSTOPPEDWorkSpaces, so this usually means the state changed during the run; check it in the console and try again.- Saving shows “check pricing”. The compute type isn’t in the script’s
PRICEtable. Add its AlwaysOn, base and hourly rates from the pricing page. AccessDeniedException. Follow the steps to troubleshoot AWS IAM access denied errors to find the policy that blocked the call.
Ask ChatWithCloud instead
For a one-off answer, ask ChatWithCloud “Which WorkSpaces in us-east-1 haven’t had a user connection in 30 days, and which are AlwaysOn?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and explains the result; how ChatWithCloud turns a question into AWS SDK calls shows the loop. It uses one profile and Region per session and runs changes without a confirmation step, so connect ChatWithCloud to a read-only AWS profile and change running modes with the script. More scripts are on the AWS practical examples hub.
Frequently asked questions
How do I see when a WorkSpace was last used?
Call DescribeWorkspacesConnectionStatus with the WorkSpace ID, or run aws workspaces describe-workspaces-connection-status --workspace-ids ws-xxxxxxxxx, and read LastKnownUserConnectionTimestamp.
Do you pay for a stopped AutoStop WorkSpace?
Yes, a small monthly base fee for the bundle, even if nobody uses it all month. The hourly charge stops while it’s stopped.
Is AlwaysOn or AutoStop cheaper?
It depends on hours of use. For a Standard Windows bundle in us-east-1, AutoStop is cheaper below about 84 hours a month and AlwaysOn above that.
How do I stop paying for an unused WorkSpace completely?
Delete (terminate) it. That removes the WorkSpace and the data on its volumes, so confirm with the owner and save anything they need first.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud