'Can't pickle LazyUser' With frappe.enqueue: The Background Job Rule Everyone Breaks
You wrote a perfectly reasonable line of Frappe code. You had a document in hand, you wanted some slow work done off the request, so you handed the document to frappe.enqueue and moved on. It works on your laptop. It works in tests. Then it hits a real background worker and blows up with a pickling error that names a class you have never heard of: LazyUser.
Here is the rule that would have saved you, stated as bluntly as it deserves: never pass a Document, a user, or a session object to frappe.enqueue. Pass strings and let the job re-fetch. Frappe's job queue serialises your arguments with pickle before it ever reaches a worker, and Frappe's own objects are full of lazily-loaded state that pickle cannot follow. The fix is not to make the object picklable. The fix is to stop sending objects at all.
RQ pickles every argument you pass to frappe.enqueue. A Frappe Document can drag in a LazyUser and other non-picklable lazy objects, which fails at enqueue time. Pass primitive identifiers instead: doctype and name as strings, then call frappe.get_doc(doctype, name) inside the job. Never pass doc, frappe.session, frappe.local, or a user object as a kwarg or captured closure variable.
The exact error
PicklingError: Can't pickle <class 'frappe.model.document.LazyUser'>: it's not found as frappe.model.document.LazyUser
You may also see it wrapped with a serialisation trail that points straight at the culprit, for example while enqueuing work triggered by a document share:
when serializing frappe.model.document.LazyUser object
when serializing dict item '_doc'
when serializing frappe.core.doctype.docshare.docshare.DocShare
That second block is the whole story in three lines. Something tried to pickle a DocShare document, that document held a _doc dict, and inside it sat a LazyUser that pickle could not resolve. Read from the bottom up: a Document went into the queue, and a lazy attribute on it killed the serialisation.
Why this happens: RQ pickles your arguments, and Documents are not plain data
Frappe's background jobs run on RQ (Redis Queue). When you call frappe.enqueue, Frappe does not run your function there and then. It packages a reference to the function plus your arguments, serialises that package with Python's pickle, and pushes the bytes into Redis. A separate worker process later pops the bytes, unpickles them, and calls your function. The two processes share nothing but the serialised blob.
Pickle works by walking an object graph and recording how to rebuild each node. For plain data (strings, ints, lists, dicts) that is trivial. A Frappe Document is not plain data. It carries references to lazily-resolved things: the owner, the modified-by user, permission state, and other attributes that are computed or fetched on first access rather than stored as simple values. LazyUser is one of these. It is a proxy that stands in for a user until you actually touch it. Pickle reaches this proxy, tries to record its class by import path, and cannot find frappe.model.document.LazyUser as a normal importable symbol, so it raises.
The failure is at enqueue time, in your web process, not in the worker. That is why the traceback points at your controller and not at the job. The object never even reached Redis.
The fix, in steps
Step 1: Identify every object you are passing across the boundary
Look at your frappe.enqueue call and every variable the enqueued function closes over. Any of these is a landmine: a Document (the return of frappe.get_doc or new_doc), frappe.session, frappe.local, a user record, or a request object. The broken pattern almost always looks like this:
# BROKEN: ships a Document (and its LazyUser) into the queue
def on_submit(doc, method=None):
frappe.enqueue(
"myapp.tasks.sync_to_warehouse",
doc=doc, # <-- this is the problem
queue="long",
)
Step 2: Pass the identity, not the object
Send the two strings that uniquely identify the document. Everything else can be re-derived.
# FIXED: ships only strings
def on_submit(doc, method=None):
frappe.enqueue(
"myapp.tasks.sync_to_warehouse",
doctype=doc.doctype,
name=doc.name,
queue="long",
)
Step 3: Re-fetch inside the job
The worker rebuilds the document from a clean database read. This is not wasteful; it is correct. By the time the job runs, the row may have changed anyway, and you want the current state, not a snapshot from enqueue time.
# myapp/tasks.py
import frappe
def sync_to_warehouse(doctype, name):
doc = frappe.get_doc(doctype, name)
# ... do the slow work with a fresh, fully-loaded document ...
frappe.db.commit()
If your slow work genuinely needs a value that is not on the document (a computed total, a user's email), pass that specific primitive as its own kwarg. Pass the value, never the object that holds it.
Step 4: Wrap the pattern so nobody reintroduces the bug
On a team, the cleanest defence is a tiny helper that only accepts identities, so passing a Document is impossible by construction.
# myapp/queue.py
import frappe
def enqueue_for_doc(path, doctype, name, **kwargs):
if not isinstance(doctype, str) or not isinstance(name, str):
frappe.throw("enqueue_for_doc needs doctype and name as strings")
frappe.enqueue(path, doctype=doctype, name=name, **kwargs)
Now the call site reads enqueue_for_doc("myapp.tasks.sync_to_warehouse", doc.doctype, doc.name) and the type check fails loudly the moment someone hands it a document. This is the same "make the wrong thing hard to write" discipline I lean on when wiring up hooks and controller methods.
Verification
Reproduce and then confirm the fix on a real worker, not just in a request. Watch the worker log while you trigger the code path:
bench worker --queue long,default,short
Then trigger the action (submit the document, run the share, whatever fired the enqueue). With the broken version you will see the PicklingError raised in the web process, and no job will appear on the worker at all. With the fixed version you will see the job picked up and completed:
default: myapp.tasks.sync_to_warehouse(doctype='Sales Order', name='SO-1') (job-id)
default: Job OK (job-id)
A cleaner programmatic check: enqueue a document-touching job, then inspect the RQ queue length or the job registry. If the job enqueues without raising and the worker completes it, the boundary is clean. If enqueue raises, you are still shipping an object somewhere, most likely in a closure.
What people get wrong
"Register LazyUser with copyreg / write a custom __reduce__." You can technically teach pickle how to serialise the proxy, but you should not. Even if it pickles, you are shipping a stale snapshot of a document across a process boundary and then acting on data that may already be out of date. The re-fetch is not a workaround for pickle; it is the correct concurrency behaviour.
"Use enqueue_doc and it handles this for you." Frappe does provide frappe.enqueue_doc(doctype, name, method, ...), and it is genuinely good for calling a method on a document because it stores doctype and name and re-fetches for you. But it does not license you to pass Document objects elsewhere. The underlying rule is unchanged: identities cross the boundary, objects do not.
"It works locally, so the code is fine." Locally you may be running jobs synchronously or in a way that skips the pickle round-trip, so the bug hides until a real Redis-backed worker serialises the arguments. Test against an actual bench worker, the way background jobs actually run in production.
When it is still broken
If you have stopped passing the document and still hit a pickling error:
- Check closures. A nested function or lambda that references
doc,self, or any Frappe object from the enclosing scope pickles that object too, even though it is not in the argument list. Pull the function out to module level and pass only primitives. - Check default arguments and partials. A
functools.partialbound to a document, or a mutable default holding one, drags it in silently. - Read the whole serialisation trail. The "when serializing ..." lines name the exact attribute path. Follow it to the object at the bottom; that is what you are accidentally shipping.
- Log it properly. Frappe swallows enqueue-time detail in some flows, so capture the full traceback with the approach in effective error reporting in Frappe before you guess.
This is an open, known rough edge in Frappe (tracked upstream in frappe/frappe issue 38799), so you may hit it through framework code paths like document sharing, not only your own enqueue calls. The defence is the same either way: keep Documents on one side of the queue boundary and identities on the other.
Frequently asked questions
- Why does frappe.enqueue raise a PicklingError about LazyUser?
- frappe.enqueue uses RQ, which serialises your job arguments with pickle before pushing them to Redis. A Frappe Document carries lazily-loaded attributes such as a LazyUser proxy, and pickle cannot resolve that class by import path, so it raises. The failure happens at enqueue time in your web process, before the job ever reaches a worker.
- How do I pass a document to a Frappe background job correctly?
- Do not pass the Document object. Pass its doctype and name as strings, then call frappe.get_doc(doctype, name) inside the job to rebuild it from a fresh database read. Alternatively use frappe.enqueue_doc(doctype, name, method), which stores the identity and re-fetches for you. Re-fetching also gives you the document's current state rather than a stale snapshot.
- Can I just make LazyUser picklable instead of re-fetching?
- You can technically register a custom reducer, but you should not. Even if it pickles, you would be shipping a stale snapshot of the document across a process boundary and acting on data that may already be out of date. Re-fetching by identity is the correct concurrency behaviour, not merely a pickle workaround.
- My code passes only strings but still throws a pickling error. Why?
- You are almost certainly capturing a Frappe object in a closure. A nested function or lambda that references doc, self, or any Document from the enclosing scope pickles that object too, even though it is not in the argument list. Move the function to module level and pass only primitive identifiers. Also check functools.partial and mutable default arguments.