Desk Clarity

A field guide to everyday digital problems.

Developer tools / Practical guide

Python dataclasses.asdict Fails on a Resource Field: Choose the Export Contract

Fix dataclass resource-field conversion by choosing shared field views or an explicit JSON schema, with mutation and copy checks.

By ON Lab5 min readSources checked November 10, 2026 (KST)

Calling dataclasses.asdict on an object containing a lock can raise TypeError: cannot pickle '_thread.lock' object. That message can appear even though your code never tried to save a pickle file. The conversion reaches an object that cannot be deep-copied using the mechanism involved. First decide whether you need a shallow field mapping, a detached data copy, or a JSON payload. Those are different contracts.

For a dataclass that owns a live resource, exporting only the intended data is often the clearest solution. Do not remove a necessary lock, close a file, or replace a resource with a string just to silence the conversion error. Preserve resource ownership and make the output schema explicit.

Reproduce the resource-field failure

This complete fixture creates a job containing a fresh list and a lock. The lock is excluded from representation and equality, but it remains a dataclass field. Those display and comparison settings do not tell asdict to skip it.

from dataclasses import dataclass, field, asdict
from threading import Lock

@dataclass
class Job:
    name: str
    tags: list[str] = field(default_factory=list)
    guard: object = field(default_factory=Lock, repr=False, compare=False)

job = Job("nightly", ["queued"])
try:
    asdict(job)
except TypeError as error:
    print(type(error).__name__, str(error))
else:
    raise AssertionError("expected lock conversion to fail")
assert job.tags == ["queued"]
assert job.guard.acquire(blocking=False)
job.guard.release()
print("original job and lock remain usable in this fixture")

The fixture checks that the original values and the lock remain usable after this particular failure. It does not promise that conversion of arbitrary custom objects has no side effects. A user-defined copy operation may execute code. Avoid treating a conversion attempt as a harmless probe on an unknown live object graph.

The documented asdict contract recursively converts dataclasses, dictionaries, lists, and tuples, and uses deep copying for other objects. It is not simply vars(instance). Its purpose also differs from a JSON encoder: a dictionary produced successfully by asdict can still contain values that JSON does not accept.

Use a shallow mapping when shared objects are intentional

If the caller needs the actual field objects, use dataclasses.fields and getattr. The resulting dictionary is new, but the values remain shared. This can be useful for internal inspection or adapters that deliberately work with the same resources. It is not a detached snapshot and is generally inappropriate as an unrestricted public response.

from dataclasses import dataclass, field, fields
from threading import Lock

@dataclass
class Job:
    name: str
    tags: list[str] = field(default_factory=list)
    guard: object = field(default_factory=Lock, repr=False, compare=False)

job = Job("nightly", ["queued"])
view = {item.name: getattr(job, item.name) for item in fields(job)}
assert view["guard"] is job.guard
assert view["tags"] is job.tags
view["tags"].append("shared-change")
assert job.tags == ["queued", "shared-change"]
print("new mapping, shared field objects")

Changing the mapping's top-level keys does not change the object's attributes. Mutating a shared list through that mapping does change the original list. Replacing a value in the mapping and mutating the object referenced by that value are different operations. Check both when reviewing a proposed shallow-conversion fix.

Using fields also avoids depending on an instance dictionary that may be absent with slots. It does not provide access control: all returned fields are still included by this comprehension. A password, token, path, or internal resource can be exposed if you use a generic field dump at an external boundary.

Choose allowed JSON fields explicitly

For a small payload, a purpose-built export function makes the field selection visible. The following schema consists of a string and a list of strings. Copying that list provides the required separation because its elements are immutable strings. This is a schema-specific exporter, not a general recursive serializer.

import json
from dataclasses import dataclass, field
from threading import Lock

@dataclass
class Job:
    name: str
    tags: list[str] = field(default_factory=list)
    guard: object = field(default_factory=Lock, repr=False, compare=False)


def export_job(job):
    if not isinstance(job.name, str):
        raise TypeError("name must be a string")
    if not isinstance(job.tags, list) or not all(
        isinstance(tag, str) for tag in job.tags
    ):
        raise TypeError("tags must be a list of strings")
    return {"name": job.name, "tags": list(job.tags)}

job = Job("nightly", ["queued"])
payload = export_job(job)
assert set(payload) == {"name", "tags"}
assert json.loads(json.dumps(payload)) == payload
payload["tags"].append("export-only")
assert job.tags == ["queued"]
assert payload["tags"] is not job.tags
print("selected fields round-trip; resource excluded; list independent")

The type checks matter because dataclass annotations alone do not enforce runtime input types. Without the list-of-strings contract, list(job.tags) might retain nested mutable objects and give a misleading impression of isolation. If the schema later includes nested structures, review the copy and encoding rules for each new field.

The exporter reads fields sequentially. It does not acquire the lock or create an atomic snapshot of concurrently changing state. If concurrency matters, take the snapshot under the same synchronization protocol used by writers. Merely acquiring a lock in an exporter is insufficient if writers ignore that lock. The examples are sequential and make no thread-safety claim.

Keep asdict for the data-copy contract it actually provides

A simple data-only dataclass can use asdict successfully. This contrast shows its useful recursive copy behavior for a nested list. The copied list can change without mutating the source. That differs from the shallow mapping above.

from dataclasses import dataclass, field, asdict

@dataclass
class Data:
    tags: list[str] = field(default_factory=list)

original = Data(["queued"])
copy = asdict(original)
assert copy["tags"] == original.tags
assert copy["tags"] is not original.tags
copy["tags"].append("copy-only")
assert original.tags == ["queued"]
print("asdict separates this ordinary list")

Passing a different dict_factory does not generally prevent recursive conversion from encountering a resource field first. Likewise, repr=False and compare=False control their named operations, not export policy. If you want metadata-driven exclusion, implement and test an exporter that actually interprets that metadata; adding metadata alone changes no conversion behavior.

Before choosing a fix, identify the failing field and the output consumer. Internal shared-object access can use a shallow mapping. Data duplication can use a copy strategy tested against that graph. An external payload needs an explicit allowed schema, encoding rules, and privacy review. Cycles, custom copy hooks, resource duplication, and concurrent consistency need separate design. The fixtures were run on macOS with CPython 3.11, 3.13, and 3.14 and do not establish those additional properties.

Sources & further reading