discogs-sdk
discogs-sdk is a modern Python client for the Discogs API covering every documented v2 endpoint, with a fluent chainable syntax and built-in response caching.
from discogs_sdk import Discogs
with Discogs() as client:
release = client.releases.get(352665)
print(f"{release.title} ({release.year})")
for result in client.search(query="Nine Inch Nails", type="artist"):
print(result.title)
Installation
pip install discogs-sdk
# or
uv add discogs-sdk
Requires Python 3.10+.
Features
- Complete Endpoint Coverage — Every route in the Discogs API v2 documentation
- Fluent API — Chain sub-resources naturally:
client.releases.get(id).rating.get() - Lazy Loading — No HTTP calls until you actually need the data
- Effortless Pagination — Browse results without managing pages or offsets
- Rate Limit Aware — Bounded retries with
Retry-Aftersupport, the live budget onclient.ratelimit, and a structured event per request - Built-in Caching — Optional TTL-based caching reduces API calls
- Flexible Auth — Supports personal tokens, consumer key/secret, or full OAuth 1.0a
- Type Safe — Get autocomplete and IDE support
- Async & Sync — Full support for both synchronous and asynchronous workflows
How it compares
The established alternative is python3-discogs-client. Both load data lazily, paginate automatically, support OAuth 1.0a and back off on HTTP 429, so the differences that matter are:
| discogs-sdk | python3-discogs-client | |
|---|---|---|
| Documented v2 API coverage | Complete | Partial † |
| Sync and async | Both | Sync only |
| Responses | Typed Pydantic models | Untyped attributes |
| Response cache | In-memory or SQLite | None |
† No inventory export or upload, release ratings or have/want stats, collection fields, folder creation, contributions or submissions.
Quick start
Authentication
Set your personal access token from your Discogs developer settings as an environment variable:
export DISCOGS_TOKEN="your-token-here"
from discogs_sdk import Discogs
client = Discogs() # reads DISCOGS_TOKEN from environment
You can also pass credentials explicitly:
client = Discogs(token="your-token-here")
The SDK supports three auth modes: personal token, consumer key/secret, and OAuth 1.0a. Exactly one mode is selected when the client is constructed, and only that mode's credentials are used. The precedence is:
- An explicit
tokenselects personal-token auth and overrides every environment credential. - Explicit OAuth access-token credentials select OAuth; any missing half may come from the matching
DISCOGS_*variable, but an unrelated environment token never takes over. - Explicit
consumer_key/consumer_secretselect consumer auth, without borrowing environment access tokens. - With no auth arguments, the environment resolves the mode:
DISCOGS_TOKEN, then a complete OAuth set, thenDISCOGS_CONSUMER_KEY/DISCOGS_CONSUMER_SECRET, then unauthenticated.
An explicitly selected but incomplete credential set raises ValueError rather than quietly falling back to a
different account or mode. See examples/authentication.py for the full OAuth flow.
Every transport failure surfaces as DiscogsConnectionError, chained to the underlying httpx2 error
(exc.__cause__). Reads retry the transient ones: network errors, timeouts, and a keep-alive connection the server
closed (RemoteProtocolError). Failures that would repeat identically — LocalProtocolError, UnsupportedProtocol,
ProxyError, DecodingError, TooManyRedirects — are raised at once for every method.
Fetching resources
# Releases, artists, masters, labels
release = client.releases.get(352665)
print(release.title) # lazy — HTTP fires here → "The Downward Spiral"
artist = client.artists.get(3857)
print(artist.name) # "Nine Inch Nails"
master = client.masters.get(3719)
label = client.labels.get(647)
These lazy resources are read-only views: assigning to one of their attributes raises AttributeError, since the
value would never reach Discogs. Where the API accepts writes, they go through a resource method such as
release.rating.update() or user.wantlist.update(). Those methods take the fields Discogs documents as typed
keyword parameters: an optional field you omit, or pass as None, is not sent, so an update leaves it unchanged, and a
misspelled name raises TypeError before any request is made.
Each identifier you pass (username, order id, release id and so on) is sent as a
single, percent-encoded path segment, so a /, ? or # in it cannot reach a
different endpoint. "." and ".." raise ValueError before any request is made.
Search
for result in client.search(query="Pretty Hate Machine", type="release", year="1989"):
print(f"[{result.type}] {result.title}")
# [release] Nine Inch Nails - Pretty Hate Machine
Sub-resources
# Community rating
rating = client.releases.get(352665).rating.get()
print(f"Average: {rating.rating.average}") # Average: 4.49
# Artist releases with sorting
for rel in client.artists.get(3857).releases.list(sort="year", sort_order="desc"):
print(f"{rel.title} ({rel.year})")
# Master versions with filters
for v in client.masters.get(3719).versions.list(format="Vinyl", country="US"):
print(f"{v.title} [{v.format}]")
Async usage
import asyncio
from discogs_sdk import AsyncDiscogs
async def main():
async with AsyncDiscogs() as client: # reads DISCOGS_TOKEN from environment
# Must await lazy resources in async mode
release = await client.releases.get(352665)
print(release.title)
# Async iteration for paginated results
async for result in client.search(query="Nine Inch Nails"):
print(result.title)
asyncio.run(main())
Collection
user = client.users.get("your_username")
# Folders
folders = user.collection.folders.list()
user.collection.folders.create(name="Industrial")
# Browse folder contents
for item in user.collection.folders.get(0).releases.list(sort="added"):
print(item.basic_information.title)
# Add a release. The response identifies the copy you just created, which is
# how you tell it apart from copies of the same release you already own.
created = user.collection.folders.get(1).releases.create(release_id=352665)
# Deep chaining: folder -> release -> instance -> fields
instances = user.collection.folders.get(1).releases.get(352665).instances
instances.get(created.instance_id).fields.update(field_id=1, value="Signed copy")
# Collection value
value = user.collection.value.get()
print(f"Median: {value.median}, Maximum: {value.maximum}")
# Wantlist — the add takes only the release id; Discogs drops notes and ratings
# sent to it, so they go in the update call
user.wantlist.create(release_id=352665)
user.wantlist.update(352665, notes="Original pressing", rating=4)
for want in user.wantlist.list():
print(want.basic_information.title)
Marketplace
# Listings
listing = client.marketplace.listings.get(123456789)
# The response identifies the new listing; it is not a full Listing.
created = client.marketplace.listings.create(
release_id=352665,
condition="Very Good Plus (VG+)",
price=25.00,
)
client.marketplace.listings.update(created.listing_id, price=22.50)
client.marketplace.listings.delete(created.listing_id)
# Orders
for order in client.marketplace.orders.list(status="Payment Received"):
print(f"Order {order.id}: {order.status}")
# Fee lookup
fee = client.marketplace.fee.get(price=25.00, currency="USD")
Error handling
from discogs_sdk import NotFoundError, RateLimitError, AuthenticationError
try:
release = client.releases.get(999999999)
_ = release.title
except NotFoundError as exc:
print(f"Not found: {exc.method} {exc.url}")
except RateLimitError as exc:
print(f"Rate limited, retry after {exc.retry_after}s")
if exc.ratelimit: # the 429's own X-Discogs-Ratelimit* headers, when present
print(f"{exc.ratelimit.used}/{exc.ratelimit.limit} used this window")
except AuthenticationError:
print("Bad credentials")
Every DiscogsAPIError names the request that failed: exc.method (upper-case) and
exc.url (fully resolved, query string included, as in RequestEvent.url), so an
error raised while iterating a listing points at the page that failed. exc.ratelimit
holds the RateLimit from the failing response's X-Discogs-Ratelimit* headers for
any status, or None when they are missing. All three are None on an exception you
construct yourself without them.
The full exception hierarchy:
DiscogsError
├── DiscogsConnectionError
├── CacheMissError
└── DiscogsAPIError
├── AuthenticationError (401)
├── ForbiddenError (403)
├── NotFoundError (404)
├── ValidationError (422)
└── RateLimitError (429)
The OAuth helpers in discogs_sdk.oauth (get_request_token, get_access_token
and their async_ variants) raise from the same hierarchy: an error status maps as
above (401 → AuthenticationError, 400 → DiscogsAPIError, …), a transport failure
raises DiscogsConnectionError, and a successful response missing either
oauth_token or oauth_token_secret raises DiscogsAPIError naming any
oauth_problem. They never retry: the access-token exchange is sent at most once.
client.exports.request() and client.uploads.create(), change() and delete()
return the new job's id, read from the Location header of the response, for
exports.get(), exports.download() and uploads.get(). A successful response
whose Location is missing or does not end in an integer raises DiscogsAPIError
with that 2xx status: Discogs accepted the request, so the job probably exists. Find
it with exports.list() or uploads.list() instead of submitting again.
str(exc) on a DiscogsAPIError is a short summary, "<status>: <message>". The
message is the body's message when that is a non-empty string, otherwise the body
itself, cut to 500 characters so a gateway error page does not flood logs; the errors
above that the SDK raises on a 2xx describe the problem instead. The full payload
stays in exc.response_body.
Every SDK exception survives pickle, copy.copy and copy.deepcopy with its
attributes (status_code, response_body, retry_after, ratelimit, method,
url) and any notes, so an error raised in a ProcessPoolExecutor or
multiprocessing worker reaches the parent as the same class. As with any pickled
exception, __cause__, __context__ and the traceback are not carried over.
A paginated iterator (.list(), search()) that raises keeps its place. Whether the
request for a page fails, the page's envelope has the wrong shape, or one of its items
fails validation (Pydantic's ValidationError), page, the totals and the URLs stay as
they were before that page, and the next next() or anext() requests the same page
again. Items already yielded are not repeated, and none are skipped.
A wrong shape raises DiscogsError naming the endpoint, the key and the JSON type
received, for example GET /users/trent_reznor/submissions: "submissions" is null, expected an object. That covers a body that is not an object, a pagination or a
non-empty pagination.urls that is not an object, and an items key that is not an
array (or, for nested items, not an object on the way). An absent items key is an
empty page.
Exceptions outside DiscogsError
Apart from the ValueErrors that reject invalid arguments, such as incomplete
credentials, the SDK lets three exceptions through on purpose, and
except DiscogsError catches none of them:
json.JSONDecodeError(aValueError) when a 2xx response that the SDK reads as JSON has a body that is not JSON; a body that is not even UTF-8 raisesUnicodeDecodeError, also aValueError. Error statuses are mapped before the endpoint parses the body, and a success is not an API error, so the SDK raises the parse failure instead of a fallback value.pydantic.ValidationError(aValueError) when a 2xx JSON body does not fit the model, such as a release whoseidis not a number. The response was a success, so this is a parsing failure rather than an API error. Only a list page's envelope is checked by the SDK itself, as described above; its items go to Pydantic like any other model.FileNotFoundError(anOSError) fromclient.uploads.create(),change()ordelete()whenfilenames a path that does not exist. The file is read before any request is sent, so there is no response to map.
Transport failures are not among them: they surface as DiscogsConnectionError, as
described under Authentication.
Two unrelated classes are named ValidationError. discogs_sdk.ValidationError is
the HTTP 422 DiscogsAPIError above and is not a ValueError;
pydantic.ValidationError is the model parsing failure. Imported under the same
name, one silently shadows the other, so alias them:
from discogs_sdk import ValidationError as ApiValidationError
from pydantic import ValidationError as ModelValidationError
Examples
The examples/ directory has runnable scripts for every feature:
quickstart.py— first requests, lazy loading, searchauthentication.py— all auth modes including OAuth 1.0adatabase.py— releases, artists, masters, labels, searchmarketplace.py— listings, orders, fees, inventorycollection.py— folders, instances, fields, wantlistasync_usage.py— async client with await and async foradvanced.py— error handling, caching, custom HTTP clients, exports
Configuration
| Parameter | Default | Description |
|---|---|---|
access_token_secret |
None |
OAuth access token secret |
access_token |
None |
OAuth access token |
base_url |
https://api.discogs.com |
API base URL; pagination next links are rebased onto it, and a link to any origin other than it or https://api.discogs.com raises DiscogsError |
cache_dir |
None |
Directory for SQLite cache; in-memory when omitted. Used only with cache=True |
cache_ttl |
3600.0 |
Cache time-to-live in seconds. Used only with cache=True |
cache |
False |
True builds the built-in backend from cache_dir and cache_ttl; or pass a ResponseCache instance (see Custom backends), which carries its own TTL and storage and is never closed by the client |
consumer_key |
None |
OAuth consumer key |
consumer_secret |
None |
OAuth consumer secret |
http_client |
None |
Custom httpx2.Client or httpx2.AsyncClient |
max_retries |
3 |
Max retries, >= 0 (0 disables retries, a negative value raises ValueError); reads retry on 429/5xx, network errors, timeouts and dropped connections, mutations only on pre-send failures. A Retry-After above 60s raises immediately instead |
media_type |
"discogs" |
Format of text fields: "discogs" (Discogs markup), "html" or "plaintext"; sent in the Accept header, and cached responses are kept apart per value |
on_request |
None |
Callback receiving a RequestEvent per request |
timeout |
30.0 |
Request timeout in seconds for the HTTP client the SDK creates; an injected http_client keeps its own (see Custom HTTP clients) |
token |
None |
Personal access token |
user_agent |
discogs-sdk/<version> +https://github.com/jmfontaine/discogs-sdk |
User-Agent header; a non-empty value replaces the default entirely |
With cache=False, the default, cache_dir and cache_ttl are ignored and no cache or directory is created; a
ResponseCache instance ignores them too. cache_ttl=0 is accepted and expires every entry at once, so each read
refetches: it is not a way to say "no limit".
See Authentication for how a credential mode is selected.
Caching
Only successful GET/HEAD responses are cached, and a GET body that is not valid JSON (a gateway's HTML page, an
empty body) never is, so a bad response fails once instead of on every read; inventory export downloads, which are CSV,
are cached as they are. A body that is valid JSON but the wrong shape for its model is still cached until it expires.
Entries are keyed by method, fully resolved URL, the effective Accept representation, and a non-reversible digest of
the selected mode's credentials, so two clients sharing one cache — or one SQLite directory — never serve each other's
private responses, and unauthenticated traffic gets its own namespace. No token or secret is stored in a key.
Job status reads, client.exports.get(id) and client.uploads.get(id), are never cached: each one reaches the API and
stores nothing, so a polling loop sees the job finish. A resolved proxy keeps its data, though, so poll with a new
get() call each time. Inside cache_only() these reads raise CacheMissError. exports.list(), uploads.list()
and exports.download() are cached as usual.
A successful write (POST, PUT or DELETE with a 2xx answer) clears the whole response cache, so no entry cached
before the write is served after it: not the written URL, not a list page, not a related folder or count. The rule
holds inside no_cache() too, and a cache shared between clients is cleared for all of them; entries are refetched on
demand, which costs requests. A failed write leaves the cache alone, and a cache that fails to clear is logged without
failing the write. Proxies and pages already resolved keep their in-memory data, so fetch a new proxy or iterate again
to see the change. Discogs itself may take a while to show a write, and a refetch made in that window caches what it
returned.
A cache built from cache=True belongs to the client, and client.close() closes it. A ResponseCache instance you
pass in stays yours, like an injected http_client: client.close() never closes it, so one cache can outlive any of
the clients sharing it. Call its close() yourself once every client using it is done.
The built-in caches drop an expired entry when the same request looks it up again, and remove every expired entry at
least once per 100 stores; the SQLite cache also does when it opens, so cache.db does not carry expired rows over
from earlier runs. Entries still within cache_ttl stay, so the cache's size is bounded by the TTL times the rate of
new responses, not by a fixed count. Deleted rows free pages inside cache.db for reuse; the file itself does not
shrink.
The SQLite cache never fails a request. If its database is locked by another process, read-only, full or corrupt,
it logs a warning on the discogs_sdk logger and carries on: a lookup counts as a miss and a store is skipped, so the
response still reaches you. Using the cache after it has been closed raises RuntimeError. The database runs in WAL
mode, so cache.db-wal and cache.db-shm may appear next to cache.db.
The SQLite database holds the full bodies of your private responses (identity, orders with buyer addresses,
collection), so put cache_dir somewhere only you can read, such as ~/.cache/my-app, never a shared path like
/tmp. A directory the cache creates is private (0o700) and cache.db is 0o600; on POSIX, an existing cache.db
that is a symlink or belongs to another user raises PermissionError. Entries keep only the Content-Type, ETag and
Last-Modified response headers.
client.no_cache() bypasses the cache for the current execution context. Scopes nest, the previous state is
restored even when the block raises, and each context carries its own scope, so leaving it in one thread or task
never affects another (a pool worker, or a task created before the block, does not see it; see
Concurrency):
with client.no_cache():
fresh = client.releases.get(352665).title # always hits the API
client.cache_only() is the mirror image: inside the scope a request the cache cannot serve — a miss, an expired
entry, a non-GET, a job status read, a disabled cache, an injected http_client without SDK credentials or an
enclosing no_cache() — raises CacheMissError before any network I/O. It nests and restores exactly like
no_cache(), so you can try everything for free first:
from discogs_sdk import CacheMissError
with client.cache_only():
try:
title = client.releases.get(352665).title # never hits the API
except CacheMissError as exc:
print(f"Not cached: {exc.method} {exc.url}")
Custom backends
The cache types are exported from discogs_sdk. To store responses elsewhere, subclass ResponseCache and implement
four methods: get(key) returns a CacheEntry, the (status_code, headers, body) tuple, or None on a miss or an
expired entry; set(key, status_code, headers, body) stores one; clear() drops every entry; close() releases
whatever the backend holds. The base constructor takes the ttl in seconds, and the backend is the one that enforces
it. A backend must be thread-safe: so that disk I/O never blocks the event loop, AsyncDiscogs may call get, set
and clear from worker threads, while clear_cache() and close() run on the calling thread, and concurrent requests
overlap. The example takes a lock for that:
import threading
from discogs_sdk import CacheEntry, Discogs, ResponseCache
class DictCache(ResponseCache):
"""Unbounded dict, no expiry."""
def __init__(self) -> None:
super().__init__(ttl=0)
self._lock = threading.Lock()
self._store: dict[str, CacheEntry] = {}
def get(self, key: str) -> CacheEntry | None:
with self._lock:
return self._store.get(key)
def set(
self, key: str, status_code: int, headers: dict[str, str], body: bytes
) -> None:
with self._lock:
self._store[key] = (status_code, headers, body)
def clear(self) -> None:
with self._lock:
self._store.clear()
def close(self) -> None:
pass
client = Discogs(cache=DictCache())
MemoryCache(ttl) and SQLiteCache(ttl, cache_dir) are the backends cache=True builds. Create one yourself to pass
the same instance to several clients, Discogs and AsyncDiscogs alike. Clients with the same credentials share
entries; keys carry a non-reversible digest of the credentials, so clients with different ones still never see each
other's private responses. Close the cache yourself once every client using it is done:
from pathlib import Path
from discogs_sdk import Discogs, SQLiteCache
shared = SQLiteCache(ttl=3600, cache_dir=Path.home() / ".cache" / "my-app")
first = Discogs(token="YOUR_TOKEN", cache=shared)
second = Discogs(token="YOUR_TOKEN", cache=shared)
print(first.releases.get(352665).title) # fetched from the API
print(second.releases.get(352665).title) # served from the shared cache
first.close()
second.close()
shared.close() # a client never closes a cache passed in
Observability
Every live response carries Discogs' X-Discogs-Ratelimit, X-Discogs-Ratelimit-Used and
X-Discogs-Ratelimit-Remaining headers. client.ratelimit holds them as a RateLimit(limit, used, remaining)
parsed from the most recent network response: a cache hit never touches it, and a response without the headers
leaves the last known value in place. It is None until the first live response.
on_request receives one RequestEvent per logical request, invoked synchronously — after a cache hit, or after the
final network response and before any error is raised, so the event exists even when the call raises. Intermediate
retried attempts do not emit; the final event carries attempts. A call that ends in DiscogsConnectionError or
CacheMissError emits nothing, since there is no response. Exceptions raised by the callback propagate unchanged;
keep it cheap and never block in it, because the async client calls it from the event loop.
| Field | Meaning |
|---|---|
method |
Upper-case HTTP method |
url |
Fully resolved URL, query string included |
status_code |
Status of the response that ended the call |
source |
"network" or "cache" |
elapsed_ms |
Final network attempt, or the cache lookup |
attempts |
Network attempts (1 = no retry); 0 on a cache hit |
stored |
Whether this call wrote the response to the cache |
ratelimit |
RateLimit from the response; always None on a cache hit |
A minimal pacing loop:
import time
client = Discogs(
token="...", on_request=lambda e: print(e.source, e.status_code, e.elapsed_ms)
)
title = client.releases.get(352665).title # lazy — the GET fires here
if client.ratelimit and client.ratelimit.remaining < 10:
time.sleep(60)
Concurrency
One client may be shared by several threads (Discogs) or tasks (AsyncDiscogs). A few things are tied to a
context or an object rather than to the client.
no_cache() and cache_only() apply to the current execution context only. A task copies its context when it is
created, so a task created before the block does not see the scope. A pool worker runs in its own context, so
with client.no_cache(): pool.map(...) still serves the workers from the cache; on a free-threaded 3.14+ build, where
a new thread starts with a copy of its creator's context, a worker spawned inside the block keeps the scope for its
lifetime instead. Either way, enter the scope inside the worker, or run the work in a copy of the context taken inside
the scope. Take a new copy for each call: one Context cannot run in two threads at once, and sharing it raises
RuntimeError.
import contextvars
from concurrent.futures import ThreadPoolExecutor
def title(release_id):
return client.releases.get(release_id).title
with ThreadPoolExecutor() as pool:
with client.no_cache():
# copy_context() runs here, inside the scope
future = pool.submit(contextvars.copy_context().run, title, 352665)
print(future.result()) # The Downward Spiral, fetched from the API
An unresolved AsyncDiscogs proxy awaited from several tasks at once sends one request and hands every task the same
model (or the same exception; a failure is not kept, so the next await tries again). That request runs in the context
of the task that awaited first, so its no_cache() or cache_only() scope, or the lack of one, applies to all of them.
Enter the scope before the first await, or call get() again inside it for a proxy of your own. Cancelling one waiting
task leaves the request and the other tasks alone.
A page returned by .list() or search() is a single-pass iterator for one consumer. Several threads or tasks
iterating the same page race on its position and can fetch a page twice and yield its items twice, and a page that
is exhausted yields nothing on a second pass. Call .list() or search() again for each consumer and each pass.
on_request runs on the thread or task that made the request, once that request completes, so events arrive in
completion order rather than start order, and with several threads the callback can run in several of them at once.
client.ratelimit is the value from whichever live response completed last, not necessarily the one with the lowest
remaining: under concurrency it is a hint for pacing, not a global count.
The cache that cache=True builds locks internally. A custom ResponseCache needs its own locking whenever several
threads share it, and with AsyncDiscogs that is always the case: the async client may call the cache from worker
threads so that disk I/O never blocks the event loop, and it calls its methods without synchronisation.
Custom HTTP clients
An injected http_client must be an httpx2 client — the SDK is built on httpx2,
Pydantic's maintained continuation of httpx. It owns its transport configuration and its lifecycle: client.close()
never closes it. SDK credentials, User-Agent and media type are still applied per request, so they describe the request
without mutating your client's defaults, and its own httpx2.Auth cannot replace credentials you gave the SDK.
An injected client's timeout is part of that transport configuration. The timeout argument only configures a client
the SDK creates, so Discogs(http_client=httpx2.Client(timeout=99.0), timeout=1.0) sends every request with the
injected client's 99-second timeout, even though client.timeout reports 1.0. To control the timeout of an injected
client, set it on that client.
When the SDK has no credentials of its own, your client's authentication is preserved and its responses are never
cached, even with cache=True, because the SDK cannot tell whose account they belong to. Inside cache_only() every
request from such a client therefore raises CacheMissError.
Field naming
Model fields use clean Python names. Where the Discogs API uses inconsistent or cryptic keys, the SDK provides a readable alias while still accepting the raw API name during deserialization:
| API field | Python attribute | Models |
|---|---|---|
anv |
name_variation |
ArtistCredit |
catno |
catalog_number |
LabelCredit, Company, LabelRelease, MasterVersion, SearchResult |
created_ts |
created_at |
Export, Upload, List_ |
curr_abbr |
currency_code |
OriginalPrice, User |
curr_id |
currency_id |
OriginalPrice |
extraartists |
extra_artists |
Release, Track |
finished_ts |
finished_at |
Export, Upload |
from |
from_user |
OrderMessage |
list_id |
id |
List_ |
modified_ts |
modified_at |
List_ |
namevariations |
name_variations |
Artist |
qty |
quantity |
Format |
sublabels |
sub_labels |
Label |
uri150 |
uri_150 |
Image |
The Python name is the typed attribute. API names still resolve as attributes at runtime, but only dynamically: a type
checker treats them like any misspelled attribute, so read them through getattr where one is in play:
release = client.releases.get(352665) # The Downward Spiral
print(release.extra_artists) # Python name, typed
print(getattr(release, "extraartists")) # API name, runtime only — same value
An API name that is a Python keyword, such as from, is reachable only through getattr(message, "from").
An API name resolves to the same normalized value as the Python name, so getattr(export, "created_ts") is a
datetime too. To read what Discogs sent, use raw.
Timestamps
Discogs sends timestamps as strings in several shapes:
| Shape | Example | Where |
|---|---|---|
| ISO 8601 with an offset | 2005-01-03T23:17:26-08:00 |
most fields |
A space instead of T |
2011-08-30 14:21:45-07:00 |
User.registered |
| No offset | 2026-10-11T07:07:34 |
Export and Upload (created_at, finished_at) |
The SDK parses every one of them into a timezone-aware datetime, so you can compare and sort values from any
endpoint. This covers Release (date_added, date_changed), CollectionItem.date_added, ListSummary
(date_added, date_changed), List_ (created_at, modified_at), User.registered, Listing.posted, Order
(created, last_activity), OrderMessage.timestamp, Export and Upload (created_at, finished_at).
- An offset that Discogs sends stays as sent.
- A value without an offset is read as
America/Los_Angelestime and gets the fixed offset in force then (-08:00or-07:00), the same kind oftzinfoas a value that arrives with one. Discogs does not document its zone; this is an assumption. An export requested at 14:07:34 UTC reported07:07:34, and the offsets Discogs does send switch between-08:00and-07:00with daylight saving. In the hour repeated when daylight saving ends, the SDK picks the first occurrence. - An empty string becomes
None. - A value the SDK cannot parse becomes
None, and the SDK logs a warning on thediscogs_sdklogger. The model still loads, andrawkeeps the string.
model_dump(mode="json") writes ISO 8601 with an explicit offset, and model_validate reads it back unchanged.
Original API values
Every model keeps the mapping it was validated from in raw. Use it to read a value as Discogs sent it, before
renaming or parsing:
release = client.releases.get(352665) # The Downward Spiral
print(release.date_added) # datetime(2005, 1, 3, 23, 17, 26, tzinfo=...)
print(release.raw["date_added"]) # '2005-01-03T23:17:26-08:00'
print(release.extra_artists[0].raw) # a nested model holds its own part of the payload
raw is the decoded response itself, not a copy; its type is a read-only Mapping, but code holding the same dict can
still change it, and later reads see the change. Holding a model keeps the whole payload in memory. model_dump() and
== ignore raw. A model you build with keyword arguments or with model_validate(model.model_dump()) reports that
input instead of an API response.
Contributing
Contributions are welcome. See CONTRIBUTING.md for guidelines.
License
discogs-sdk is licensed under the Apache License 2.0.
Metadata
Release files for discogs-sdk 0.7.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| discogs_sdk-0.7.0.tar.gz | 101.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| discogs_sdk-0.7.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 216.4 kB
Release files / discogs_sdk-0.7.0.tar.gz
| Download URL | discogs_sdk-0.7.0.tar.gz |
|---|---|
| Size | 101.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f198c899afb30292b55dbae7c780d3aed24b6f34d0a80ae72da4a4910382a993
|
|
BLAKE2b-256 checksum How to use checksums |
2608e7196bb065aadf011917dc9abbd19b01dcdc72e2074d4c8d711189495f86
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 11, 2026.
Transparency logRelease files / discogs_sdk-0.7.0-py3-none-any.whl
| Download URL | discogs_sdk-0.7.0-py3-none-any.whl |
|---|---|
| Size | 115.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
39717a20033c5d8b477ce2aea17c7511eb8416705218343109a7e434dafc4d63
|
|
BLAKE2b-256 checksum How to use checksums |
931d9d3a4233749f3aca32e134fd18ed80b3b09246cff2366f34ed209222d04c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 11, 2026.
Transparency log