Scans your Steam library and sorts every game into a Steam collection based on what the game actually is — FPS, Soulslike, Metroidvania, Roguelite, Farming, Visual Novel, and 30-odd more.
Your games are never touched. The only thing this writes is the collections document inside your Steam user config. Every write is preceded by an automatic backup, and the default mode is a dry run that changes nothing.
Get the latest release for Windows — unzip it anywhere and run
SteamLibraryOrganizer.exe. No Python needed.
Windows will show a blue "Windows protected your PC" box the first time, because the executable is not code-signed (signing certificates cost money). Click More info → Run anyway. If you would rather not take that on trust, run it from source instead — it is the same code, three commands below.
Then: close Steam, press Preview, look at what it plans to do, press Apply.
- Download
- What it does
- Requirements
- Installation
- Quick start
- How categorisation works
- Franchises and keeping the list short
- Categories
- Configuration
- Command line
- Backups and restore
- Where Steam keeps collections
- Building an executable
- Project layout
- Troubleshooting
- Testing
- Finds Steam via the registry (Windows) or the standard install paths, then
reads every library folder from
libraryfolders.vdf. - Builds the game list from four sources: installed games from
appmanifest_*.acf, owned games from the Steam Web API (optional key), family-shared games fromIFamilyGroupsService(optional access token), and apps this account has launched, fromlocalconfig.vdf. - Fetches metadata from the Steam Store API (genres, store features, app type), SteamSpy (Steam user tags with vote counts) and the store page itself for the games SteamSpy does not know, cached in SQLite.
- Scores each game against a rule set and picks the best-fitting category.
- Groups series and folds thin categories so the result is a sidebar you can actually use rather than forty near-empty rows.
- Previews the plan, then — only when you say so — merges it into your Steam collections, backing up first.
Highlights:
- Handles 1000+ game libraries; rate-limited, cached, and resumable.
- Detects franchises from titles — one
Resident Evilcollection instead of eighteen games diluting four genres. - Never creates near-duplicate collections — "First Person Shooter", "Shooter"
and "FPS" all normalise to
FPS. - Adds to your existing collections rather than replacing them (configurable).
- Never modifies dynamic collections (the ones with a saved filter), because rewriting their membership would destroy the filter.
- Second run over an unchanged library issues zero network requests.
- Python 3.9+ (developed and tested on 3.14)
- Tkinter for the GUI — bundled with the python.org installer on Windows and
macOS. On Debian/Ubuntu:
sudo apt install python3-tk requests— the only third-party runtime dependency- Windows, macOS or Linux. Steam integration is fully implemented on all three; Windows is what it has been tested against.
git clone https://github.com/okabeeer/SteamLibraryOrganizer.git
cd SteamLibraryOrganizer
pip install -r requirements.txtLaunch the GUI:
python -m steam_organizerOn Windows you can also double-click Run Steam Library Organizer.bat.
pip install .
steam-organizer # GUI
steam-organizer preview # CLINo Python needed on the target machine — see Building an executable.
- Close Steam completely. Check the system tray — Steam rewrites its config when it exits, so anything written while it is running gets discarded. The app refuses to write while Steam is up.
- Launch the GUI.
- Confirm the account picker shows the right profile.
- (Optional) Paste a Steam Web API key to include games you own but have not installed.
- Click Preview (dry run). The first run on a large library takes a while — the Steam store API allows roughly 200 requests per 5 minutes, so budget about 25 minutes for 1000 games. Results are cached, so later runs are near instant.
- Review the Preview tab: collections are listed with their games and the signals behind each decision.
- Click Apply to Steam, then start Steam.
To undo everything: Backups… → Restore selected.
Each game is scored against every category rule. Three signal groups feed in:
| Signal | Source | Role |
|---|---|---|
| User tags | SteamSpy, then the store page | Primary evidence. Weighted by vote share, so a game's headline tag counts for far more than a fringe one. |
| Genres | Steam Store API | Corroboration. Broad and reliable, but Steam lists "RPG" as a genre for most action games. |
| Store features | Steam Store API | Single-player, Co-op, VR Supported, MMO — the only sensible source for play-mode categories. |
Tags decide almost everything, so a game without them is being guessed at from
two or three broad genres. SteamSpy has no entry for roughly one game in seven —
recent releases, niche titles, regional ones — and that is where the bad
assignments come from. SILENT HILL f lists its genres as Action and Adventure
and nothing else, which files a survival horror game under Adventure.
store_page_tags closes the gap by reading the tag list straight off the store
page, where Steam embeds it with vote counts. On a 770-game library this took
tag coverage from 84% to 99% and fixed, among others:
| Game | Genres only | With store-page tags |
|---|---|---|
| SILENT HILL f | Adventure | Horror |
| Hollow Knight: Silksong | Adventure | Metroidvania |
| Deadlock | Action | MOBA |
| Fear & Hunger | Adventure | Horror |
| SUPER PEOPLE | Action | Battle Royale |
| Ori and the Blind Forest | Action | Metroidvania |
The default ("missing") scrapes only games SteamSpy could not answer for, which
costs one extra request each. "always" prefers the store page for every game —
its counts are live rather than SteamSpy's estimates — at the price of a much
longer first run. Store pages and the store API share one rate-limit bucket,
because they are the same host.
After changing this setting, run steam-organizer clear-cache --tagless to
refetch just the games that lack tags instead of the whole library.
Four rules make the output good rather than merely plausible:
- No synonym stacking. Within a signal group the strongest match sets the
score; the rest only add a small corroboration bonus. Without this, a broad
rule that lists many synonyms ("rpg", "action rpg", "crpg", …) out-accumulates
a specific rule that legitimately matches once — and every Soulslike lands in
RPG. - Genres are damped when tags exist. A decisive user tag should beat a generic genre listing. With no tags at all (unreleased games), genres carry full weight, since they are then the only thing known.
- Suppression, not just competition.
MetroidvaniaremovesPlatformerfrom contention entirely;RogueliteremovesRoguelike; a third-person shooter is not also anFPS. - Specificity breaks ties. Sub-genres (
Soulslike,JRPG,Battle Royale) outrank genres (FPS,Horror), which outrank broad buckets (RPG,Adventure), which outrank catch-alls (Action,Indie).Indieis damped hardest — nearly half of Steam carries that tag.
Worked example, DARK SOULS III:
tags: Souls-like=9604, Dark Fantasy=7905, Difficult=6967, RPG=5966,
Action RPG=3576, Co-op=3499, …
Soulslike 4.62 tag:souls-like (top tag, full strength)
RPG 2.55 tag:rpg + damped tag:action rpg + damped genre
Open World 1.99
→ Soulslike
Games where nothing scores above min_confidence go to fallback_category
(default Uncategorized), or are skipped if you blank that setting.
Correct categories are not automatically useful ones. A large library scores
into forty-odd collections, several holding two games each, while ten Assassin's
Creed titles sit scattered across Open World, Action and Stealth telling
you nothing their names did not already say. Two passes run over the whole
library after scoring to fix that.
A series with franchise_min_games entries (default 5) gets a collection of its
own, and by default its games leave their genre collections — one
Assassin's Creed row instead of ten games diluting three genres.
Detection is name-based, because the title is the only signal present for every
game, including family-shared, delisted and never-launched ones. Titles are
stripped of edition noise (Definitive Edition, GOTY, Remastered), split
into words, and every shared prefix becomes a candidate. What makes it usable is
what it rejects:
| Rule | Prevents |
|---|---|
| A name may not end on a sequence number | Assassin's Creed II as a series name |
| A name may not end on a possessive | Tom Clancy's swallowing Splinter Cell, Ghost Recon and Rainbow Six |
| A one-word name needs a game actually called that, or that word plus a number, or seven distinctive characters | Sniper Elite merging with Sniper Ghost Warrior |
| A name may not end on an auxiliary verb | We Were instead of We Were Here |
| The longest name covering the same games wins | Resident instead of Resident Evil |
| Otherwise the shortest name wins | Yakuza Kiwami splitting off from Yakuza |
Runs of single letters collapse, so S.T.A.L.K.E.R. and F.E.A.R. survive
intact, and the display name is cut from the original title, keeping punctuation
(Marvel's Spider-Man, Watch_Dogs).
Tune it with franchise_min_games; force or split groups it got wrong with
franchise_overrides; drop one with franchise_ignore. Set
franchise_mode to "extra" to keep the genre as well, or "off" to disable.
Steam sorts collections alphabetically, so series names interleave with genres.
Set franchise_prefix to something like "* " to keep them together.
Any category with fewer than min_games_per_category games (default 5) folds
into a broader parent — RTS and Turn-Based Strategy into Strategy,
City Builder and Farming into Simulation, Story Rich into Adventure —
repeating until it is either big enough or a root category. Roots are left alone
however small they are; VR has nowhere sensible to go.
When a fold empties a collection an earlier run created, that collection is
deleted (retire_merged_collections). This only ever applies to collections
named after one of the built-in categories, so a collection you made yourself is
never removed, whatever it is called. Dynamic collections are always skipped.
Folding is a threshold, which means you pick a number of games and find out
how many collections you got afterwards. It also cannot go below about
twenty-five on a large library, because root categories like Racing and VR
have no parent to fold into.
max_categories is the direct control. The smallest category is dropped
repeatedly until the budget is met, and each of its games moves to its own
next-best category — the same scoring that picked the original, not a blanket
redirect.
It is best-effort, not a guarantee, because three rules can stop a drop:
- The replacement must already exist. A budget that shrinks the list by inventing a new collection to hold the refugees has shrunk nothing.
- The fit must hold up. If the typical game would land in a category
scoring below
BUDGET_QUALITY_RATIO(0.7) of its current fit, the category stays. This is what stops football games scoringSports 5.21being filed underActionat0.85— a real result from an earlier version, and the reason the guard exists. - No game is left stranded. A category holding a game with nowhere at all to go is spared rather than dropping it out of every collection.
So asking for 18 on a library with 28 genuine genres will give you more than 18.
That is the honest answer: accuracy and a short list are in direct tension,
and past a point the budget is buying tidiness with wrong categories. Leave it
at 0 unless you specifically want that trade.
Series and the fallback do not count towards the budget. Cap series separately
with franchise_max_collections, which keeps the largest.
Measured on a 770-game library:
| Settings | Collections | Series | Genre | Uncategorized |
|---|---|---|---|---|
| Both passes off | 36 | 0 | 36 | 5 |
| Defaults (series at 5, fold at 5) | 44 | 16 | 28 | 5 |
max_categories: 18 |
35 | 16 | 19 | 7 |
max_categories: 18, series cap 10 |
29 | 10 | 19 | 9 |
max_categories: 15, series cap 8 |
24 | 8 | 16 | 10 |
max_categories: 12, series cap 6 |
19 | 6 | 13 | 14 |
max_categories: 15, no series |
16 | 0 | 16 | 11 |
Those counts predate the fit guard, so a tight budget now lands above the number
asked for rather than mangling categories to reach it. Preview before applying —
the summary lists every merge, and a merge like Co-op -> Puzzle is the signal
that the budget is set too low.
Sub-genre Soulslike Metroidvania Roguelike Roguelite JRPG MOBA
Battle Royale City Builder Colony Sim Farming
Visual Novel Rhythm Fighting Racing MMO VR
Genre FPS Third-Person Shooter Horror Stealth Platformer
Puzzle RTS Turn-Based Strategy Survival Open World
Sandbox Building Sports Party Games
Broad RPG Strategy Simulation Adventure Story Rich
Catch-all Action Indie Co-op Multiplayer Singleplayer
steam-organizer categories prints these with their weights.
Missing categories are created automatically. Existing collections with a matching name (case-insensitive) are reused, never duplicated.
Settings live in a JSON file. Locations:
| Platform | Path |
|---|---|
| Windows | %APPDATA%\SteamLibraryOrganizer\config.json |
| macOS | ~/Library/Application Support/SteamLibraryOrganizer/config.json |
| Linux | ~/.config/steam-library-organizer/config.json |
You never have to write it by hand: Save settings in the GUI creates it, and
steam-organizer init-config writes one with the defaults. Every option is
listed below.
The options worth knowing:
| Option | Default | What it does |
|---|---|---|
dry_run |
true |
Preview without writing. |
multi_category |
false |
One collection per game. true also adds strong runners-up. |
max_categories_per_game |
3 |
Cap when multi_category is on. |
min_confidence |
0.8 |
Raise it to push more games to the fallback. |
fallback_category |
"Uncategorized" |
Blank it to skip unmatched games instead. |
ignore_categories |
[] |
Names to never create, e.g. ["Indie", "Singleplayer"]. |
custom_mappings |
{} |
Force a category by app ID or exact game name. |
replace_existing_membership |
false |
true rewrites managed collections instead of adding to them. |
franchise_mode |
"primary" |
"extra" keeps the genre too; "off" disables series detection. |
franchise_min_games |
5 |
Games a series needs to earn a collection. |
franchise_prefix |
"" |
Prepended to series names so they sort together. |
franchise_overrides |
{} |
Force a game into a series by app ID or name. |
franchise_ignore |
[] |
Series never turned into a collection. |
min_games_per_category |
5 |
Thinner categories fold into their parent. 0 keeps them all. |
max_categories |
0 |
Best-effort cap on genre collections. 0 means no cap, and is the accurate setting. |
reconcile_membership |
true |
Move a game out of a collection it no longer belongs to. |
franchise_max_collections |
0 |
Hard cap on series collections. 0 means no cap. |
retire_merged_collections |
true |
Delete built-in-named collections a fold emptied. |
store_page_tags |
"missing" |
Scrape tags from the store page when SteamSpy has none. "always" / "off". |
steam_web_api_key |
"" |
Include owned-but-not-installed games. |
steam_access_token |
"" |
Include games shared by a Steam Family. |
include_family_shared |
true |
Use the family library (needs the token). |
backup_before_write |
true |
Leave this on. |
require_steam_closed |
true |
Leave this on. |
Custom mappings override all scoring:
"custom_mappings": {
"570": "MOBA",
"stardew valley": "Comfy",
"1245620": ["Soulslike", "Open World"]
}Category names in custom_mappings and ignore_categories are normalised too,
so "first person shooter" and "FPS" mean the same thing.
steam-organizer [--config PATH] [--steam-path PATH] [--account ID] [-v|-q] <command>
| Command | Purpose |
|---|---|
gui |
Launch the desktop interface (default when no command is given). |
preview |
Show what would change. --json for machine-readable output. |
apply |
Write the collections. --yes skips the prompt, --no-backup skips the backup. |
accounts |
List local Steam accounts and which one is used by default. |
collections |
List the collections currently in Steam. |
probe |
Dump the raw collection store and the detected schema. |
categories |
List every category the rule set can assign. |
backup |
Take a backup right now. |
list-backups |
List saved backups. |
restore [PATH|latest] |
Restore a backup. |
remove-collections [--categories] [--include-dynamic] [--yes] |
Delete collections in bulk. Previews unless --yes. |
clean-legacy |
Clear collections from the legacy localconfig.vdf store. |
clear-cache [--tagless] |
Delete cached metadata. --tagless drops only entries with no user tags. |
init-config |
Write a config file with the defaults. |
steam-organizer preview # see the plan
steam-organizer preview --json > plan.json
steam-organizer apply --yes # write it
steam-organizer restore latest # undoBackups are written to the app-data folder under backups/, keeping the most
recent max_backups (default 20). Each backup is two files:
collections-<timestamp>.json— the parsed collections plus the exact raw namespace document, which is what a restore replays.collections-<timestamp>.localconfig.vdf— a verbatim copy of the source file, for manual rollback.
Restoring takes a fresh backup of the current state first, so a mistaken restore is itself reversible.
A backup failure aborts the run that requested it — it never degrades to a warning and writes anyway.
Remove all collections… in the GUI, or steam-organizer remove-collections,
deletes collections in bulk so you can re-run from a clean library instead of
right-clicking forty of them in Steam.
What it will not touch:
- Steam's own collections — Favorites, Hidden and the
from-tag-*ones. They are recognised by their fixed IDs, not their names, so renaming one cannot expose it. Everything a person creates gets auc-ID. - Dynamic collections, unless you pass
--include-dynamic. Their membership comes from a saved filter, which is real work. - Games. A collection is a list of app IDs; deleting one changes nothing about what is installed or owned.
--categories narrows it to the built-in category names, leaving series
collections and anything you named yourself alone. The CLI previews by default
and needs --yes to actually delete; the GUI names every collection in the
confirmation. A backup is always taken first — though note a restore replays the
whole namespace, so it brings back everything from that moment, not just the
part you regret.
Steam has used three locations over the years. The tool auto-detects which one
your client actually uses — run steam-organizer probe to see.
<Steam>/userdata/<account_id>/config/cloudstorage/
cloud-storage-namespace-1.json the key-value store
cloud-storage-namespace-1.modified.json keys awaiting upload
cloud-storage-namespaces.json highest version per namespace
cloud-storage-namespace-1.json is compact JSON: an array of [key, entry]
pairs holding Steam's whole cloud key-value store, of which collections are only
a slice. A collection entry looks like this:
["user-collections.uc-AbC123", {
"key": "user-collections.uc-AbC123",
"timestamp": 1785986592,
"value": "{\"id\":\"uc-AbC123\",\"name\":\"FPS\",\"added\":[730,550],\"removed\":[]}",
"version": "4546",
"conflictResolutionMethod": "custom",
"strMethodId": "union-collections"
}]Three things make writing this correctly non-trivial, and all are handled:
versionis a namespace-global counter. New and changed entries must take versions above every existing one, andcloud-storage-namespaces.jsonhas to record the new high-water mark.- Changed keys must be listed in
*.modified.json, or the client may never upload the change and can later overwrite it with the cloud copy. - Deleted collections leave tombstones — entries with
is_deleted: trueand novalue. They are preserved exactly; resurrecting one would bring back a collection the user deleted.
Everything that is not a collection passes through byte-for-byte, and collections whose membership did not actually change keep their original version and timestamp, so re-running does not churn your sync state.
UserLocalConfigStore → WebStorage → user-collections in
<Steam>/userdata/<account_id>/config/localconfig.vdf, holding the same document
escaped into a single VDF string. Current clients still keep this key but
ignore its contents — writing here has no visible effect. If a previous tool
left data there, steam-organizer clean-legacy clears it (after backing it up).
%LOCALAPPDATA%\Steam\htmlcache\...\Local Storage\leveldb, key
U<steamid64>-cloud-storage-namespace-1. Reading works out of the box; writing
needs the optional plyvel package.
For the legacy backends the envelope shape varies between client versions, so the tool infers it from whatever is already on disk and writes new entries to match, rather than hard-coding one version's format.
python packaging/build.pyThis creates an isolated .venv-build environment so PyInstaller never touches
your system Python, then produces dist/SteamLibraryOrganizer/ containing:
SteamLibraryOrganizer.exe— windowed GUI, no console flashsteam-organizer-cli.exe— console build for the CLI subcommands
The whole folder is the application; zip it to distribute.
python packaging/build.py --system # use the current interpreter instead
python packaging/build.py --clean # remove build artefactssteam_organizer/
vdf.py Dependency-free VDF (KeyValues) parser and writer
config.py Configuration model, JSON persistence, app-data paths
logging_setup.py Rotating file + console logging
organizer.py The run pipeline; the GUI and CLI are shells over this
cli.py Command-line interface
steam/
paths.py Install, library folder and account discovery
library.py Merged game list from manifests, Web API and localconfig
collections.py Collection store: read, write, schema detection, merge
backup.py Backup, prune and restore
metadata/
models.py The normalised GameMetadata record
sources.py Steam Store, SteamSpy, store-page and Web API clients
ratelimit.py Token bucket and retry policy
cache.py SQLite metadata cache and resumable job state
provider.py Source merging, concurrency, progress reporting
categorize/
rules.py Category vocabulary, matchers, aliases, parent chains
engine.py Scoring, category selection, franchise and merge passes
franchise.py Series detection from game titles
ui/
app.py Main window
settings_dialog.py Advanced settings
dialogs.py Backup browser
tooltip.py Hover help
theme.py Dark ttk theme
tests/ Unit tests — no network, no Steam install needed
packaging/ PyInstaller spec, entry script and build driver
The layering is strict: steam/ knows nothing about metadata, metadata/ knows
nothing about Steam files, categorize/ is pure logic over a metadata record,
and ui/ only talks to organizer.py.
"Steam is running" — Close it fully, including the system tray icon. Steam
rewrites localconfig.vdf on exit, so it would overwrite anything written while
it is up. If you must override this, set require_steam_closed: false; expect
your changes to be lost.
Collections do not appear after applying — Run steam-organizer probe and
check the Backend: line:
cloudstorageis what current clients read. If the probe lists your collections but Steam does not show them, make sure Steam was fully closed when you applied — it rewrites this file on exit.localconfig.vdfon a modern client means thecloudstoragefolder was missing. Launch Steam once, sign in, close it, and try again.leveldbneedspip install plyvelto be writable.
"No Steam Web API key configured" — Only installed games and games this account has launched will be found. Get a key at https://steamcommunity.com/dev/apikey and make sure the profile's Game details privacy setting is Public.
Hundreds of games still show under Steam's "Uncategorized" — Steam's "Uncategorized" is its own built-in bucket: every library game that is in no collection at all. Games end up there when the tool never saw them, which has two usual causes:
- No Web API key, so games you own but have never installed or launched are invisible. Local files alone typically know about only a third of a large library.
- Steam Family sharing. Shared games are owned by another account, so they
appear in none of the other sources — not
appmanifest, notGetOwnedGames, notlocalconfig.vdf. Setsteam_access_tokento pick them up.
Run steam-organizer preview and compare "Games scanned" against the size of
your library in Steam. If it is much smaller, one of the two above applies.
A library folder is reported unreachable — That drive is not currently connected. Games installed there are skipped; reconnect the drive and re-run.
The first run is slow — Expected. The Steam store API allows roughly 200 requests per 5 minutes. Results are cached for 30 days, so subsequent runs take seconds. An interrupted run resumes where it stopped.
A game is in the wrong collection — Add a custom_mappings entry. Metadata
quality varies, and unreleased games often have no user tags at all, leaving only
broad genres to go on.
Rate limited / connection reset — The tool backs off and retries automatically. Persistent failures are reported per game and do not abort the run.
Logs — steam-organizer.log in the app-data folder, or the Log tab.
pip install ".[dev]"
python -m pytest tests -q138 tests, none of which need the network or a Steam install. They cover VDF parsing and round-trip fidelity; the cloudstorage backend (version counters, sync bookkeeping, tombstone preservation, and that unrelated cloud data is never touched); which collections may be deleted in bulk; schema detection; the merge rules including dynamic-collection protection; franchise detection against real Steam titles; category consolidation and the budget's fit guard; and scoring against real tag data captured from the live APIs.
They run on every push — see .github/workflows/tests.yml.
- Games are never installed, uninstalled, moved or deleted.
- Only
user-collections.*entries are written. Tests confirm every other entry in the cloud namespace — and every other key inlocalconfig.vdf— is byte-identical afterwards. - Existing collections are added to, not replaced.
reconcile_membershipadditionally removes a game from a collection it is no longer assigned to, but only for games the run actually examined — a library that shrank cannot strip collections of what it failed to see. - Bulk removal never touches Steam's own Favorites, Hidden or tag-derived collections, nor dynamic ones unless explicitly asked. It previews first and backs up before writing.
- A collection is deleted only when a fold has emptied it, only when its name is
one of the built-in categories, and only with
retire_merged_collectionson. Collections you named yourself are never candidates, and dynamic collections never are either. Every deletion is preceded by a backup and reported in the summary. - A backup taken from one storage backend can never be restored into another.
- Writes go to a temp file and are swapped in atomically, so a crash mid-write cannot truncate your config.
- Backups are automatic, and restore is one click.
- Dry run is the default everywhere.
MIT