Skip to content
cbyte edited this page Jul 20, 2026 · 1 revision

PICS (Product Info)

PICS — the Product Info Catalog Server — is Steam's authoritative registry of app and package metadata. Every app on Steam (games, tools, videos, DLC, playtests) and every package (bundles, subscriptions, licenses) has a PICS entry; the PICS response is the single source of truth for that entry's contents.

You reach PICS through the CM protocol — i.e., through SteamClient after a login. There is no HTTPS front for PICS. If you're just curious about a public app's name / type / depots, an anonymous login is enough; if you want per-license info (depot access, packages you own, DRM specifics), you need a real account login.

When to use PICS vs. the alternatives

Steam data lives across three surfaces with very different characteristics. Pick the right one for the field you want:

Field PICS (SteamClient.get_product_info) Store HTTP (store.steampowered.com/api/*) Web API (api.steampowered.com/*)
name, type, oslist, associations ✅ authoritative ⚠️ same data, per-app HTTP
Depots, manifest ids, launch configs, DRM only source
Current price, discount, currency only source
Review counts / positive-percentage ✅ (/appreviews/<appid>) ⚠️ some via ISteamApps.*, worse ergonomics
Vanity URL / SteamID resolution ✅ (ISteamUser.ResolveVanityURL)
Player counts ⚠️ via CM ClientGetNumberOfCurrentPlayers (also on SteamClient) ✅ (ISteamUserStats.GetNumberOfCurrentPlayers)
Owned games / user library ❌ (needs Web API key or logged-in account) ✅ (IPlayerService.GetOwnedGames)
Batch-friendly? 500+ apps per call ❌ one HTTP round-trip per app ✅ some endpoints
Needs Web API key? ❌ (public endpoints) ✅ (most endpoints)
Rate limit Per CM session (generous) Per-IP, ~30 req/min without a key Per-key, ~100k/day

Rule of thumb:

  • App metadata by ID (name, type, depots, oslist, associations, DLC list, developer/publisher, launch configs, DRM keys) → PICS. It's the only source and it batches.
  • Price / storefront / current discountstore HTTP. PICS doesn't carry price.
  • Reviews / recommendation summarystore HTTP (/appreviews/<appid>).
  • Account-scoped data (owned games, wishlist, stats, achievements) → Web API with a key.
  • Vanity URL resolution, workshop items, market listingsWeb API.

If a data set spans surfaces, hit multiple. The scraper in scripts/steam_scrape/ shows the common pattern: PICS for names in one batched call, store HTTP for per-app price + reviews.

Basic recipe — batch app info

from steam.client import SteamClient

client = SteamClient()
assert client.anonymous_login() == client.EResult.OK

resp = client.get_product_info(
    apps=[570, 730, 553850],   # Dota 2, CS2, Helldivers 2
    timeout=30,
)

for appid, info in resp['apps'].items():
    name = info.get('common', {}).get('name')
    app_type = info.get('common', {}).get('type')
    print(f'{appid}: {name} ({app_type})')

client.logout()
client.disconnect()

Output:

570: Dota 2 (game)
730: Counter-Strike 2 (game)
553850: HELLDIVERS™ 2 (game)

Signature

get_product_info(
    apps=[],                    # list[int | dict]
    packages=[],                # list[int | dict]
    meta_data_only=False,       # bool
    raw=False,                  # bool
    auto_access_tokens=True,    # bool
    timeout=15,                 # seconds per response chunk
) -> dict | None

Returns:

{
    'apps': {
        570: {
            'common': {'name': 'Dota 2', 'type': 'game', ...},
            'extended': {...},
            'config': {...},
            'depots': {...},
            'ufs': {...},
            '_missing_token': False,
            '_change_number': 12345678,
            '_sha': 'abc123...',
            '_size': 4321,
        },
        ...
    },
    'packages': {
        123: {
            'packageid': 123,
            'billingtype': 10,
            'licensetype': 1,
            'status': 0,
            'extended': {...},
            'appids': [...],
            '_missing_token': False,
            ...
        },
    },
}

Returns None if both apps and packages are empty.

Parameters in detail

  • apps=[…] — list of app ids to fetch. Each item is either an int (570) or a dict with an appid key and optional access_token. Pass hundreds at a time; PICS pages large lists across multiple response chunks.
  • packages=[…] — same shape for package ids. Package tokens live on client.licenses[<packageid>].access_token after login.
  • meta_data_only=True — skip the VDF payload; only _change_number, _sha, _size, _missing_token come back. Fast when you're just checking whether an app changed since your last read.
  • raw=True — payloads returned as raw bytes under _buffer (text VDF for apps, binary VDF for packages) instead of being parsed into dicts. Useful if you have a pre-built VDF parser.
  • auto_access_tokens=True (default) — the client calls get_access_tokens() first and threads the results in. Turn off if you already have tokens or want to skip the extra CM round-trip.
  • timeout=15 — seconds per response chunk. A batch of 500 apps might come back as 3–4 chunks; the timeout is per-chunk, not per-call.

Access tokens

Some apps' PICS entries are gated on an access token. Anonymous logins can see the "common" data on nearly every public app; certain fields (extended metadata, private betas, unreleased content) require a token, and the entry comes back with _missing_token=True:

resp = client.get_product_info(apps=[555555])
if resp['apps'][555555]['_missing_token']:
    tokens = client.get_access_tokens(app_ids=[555555])
    resp = client.get_product_info(apps=[{
        'appid': 555555,
        'access_token': tokens['apps'][555555],
    }])

Access tokens require an authenticated login for the account that owns / can-see the app — anonymous logins get zeros for anything gated.

Change tracking

Every PICS response includes _change_number. Save it, then poll get_changes_since(change_number) on a schedule to learn which apps and packages have new revisions:

resp = client.get_product_info(apps=[570, 730])
last_change = max(
    info['_change_number']
    for info in resp['apps'].values()
)

# … later …

changes = client.get_changes_since(last_change, app_changes=True)
for change in changes.app_changes:
    print(f'app {change.appid} changed → cn={change.change_number}')

This is the same primitive Steam's own launcher uses to know when to re-fetch depot manifests. Way cheaper than re-polling get_product_info for a large watchlist.

Meta-data only

Use meta_data_only=True when you only want to know whether something changed:

resp = client.get_product_info(apps=[570, 730], meta_data_only=True)
for appid, info in resp['apps'].items():
    print(appid, info['_change_number'], info['_sha'])
# 570 12345678 abc123...
# 730 12345678 def456...

The response is dramatically smaller. Combine with a persisted change-number cache to skip full fetches for unchanged apps.

Full example — scraping app names in bulk

Fetch names for a large watchlist in one batched call, then look them up:

from steam.client import SteamClient

APP_IDS = [570, 730, 440, 4000, 730, 570]  # duplicates fine — dedup on return

client = SteamClient()
assert client.anonymous_login() == client.EResult.OK

# Deduplicate first — PICS won't reject dupes but there's no point in wasting
# the wire bytes.  A single call handles ~500 apps comfortably.
apps = list(set(APP_IDS))
resp = client.get_product_info(apps=apps, timeout=30)

names = {
    appid: info.get('common', {}).get('name')
    for appid, info in resp['apps'].items()
}

client.logout()
client.disconnect()

for appid in APP_IDS:
    print(appid, names.get(appid))

For 500-item batches or larger, split them into chunks of ~500 and iterate — a chunk that big generally arrives across 3–5 PICS response messages, still one get_product_info call.

Where PICS lives in the code

  • Public methodSteamClient.get_product_info (via the Apps mixin in steam/client/builtins/apps.py).
  • Related methods on the same mixinget_access_tokens, get_changes_since, get_app_ticket, get_encrypted_app_ticket, get_depot_key, get_player_count.
  • EnumsEMsg.ClientPICSProductInfoRequest / Response / AccessTokenRequest / Response / ChangesSinceRequest / Response live in steam/enums/emsg.py.
  • Consumersteam/client/cdn.py uses get_product_info to look up depots for downloads.

Where to go next

  • Downloading actual depot files? CDNClient uses PICS internally to resolve depots.
  • Need session cookies for store.steampowered.com after login? See SteamClientWeb mixin, or stand up WebAuth standalone.
  • Want to hit api.steampowered.com endpoints instead? WebAPI is the right surface — but for app metadata by ID, PICS is faster and doesn't need a key.

Clone this wiki locally