Skip to content

Migrating from v5 to v6

Python version requirement raised to 3.13

v6 drops support for Python 3.12. The minimum required version is now Python 3.13.

If you are still on Python 3.12, upgrade your runtime before updating pypaperless to v6.


v6 is also almost a full rewrite of pypaperless. Three things drove it:

  • Models were too tightly coupled to the HTTP layer. In v5, every model instance carried a reference to the client and called it directly. That made testing awkward and sharing models between contexts impossible. v6 models are plain data - all I/O goes through services.
  • No runtime type safety. v5 used dataclasses with manual dict conversion, so bad API responses would silently produce wrong values. v6 uses Pydantic v2, which validates every response at parse time.
  • aiohttp got removed. httpx is modern, has a cleaner sync/async API and a built-in mock transport that makes testing easier.
  • Model-level CRUD shortcuts removed — replaced by a dispatcher. doc.update(), doc.delete(), and draft.save() are gone. Instead, call the operations directly on the PaperlessClient; the dispatcher routes to the correct service automatically: await paperless.update(model), await paperless.delete(model), await paperless.save(draft). See CRUD.

Quick checklist

# What to change Section
1 Replace aiohttp / yarl with httpx Dependencies
2 Rename class PaperlessPaperlessClient; PaperlessConfigPaperlessSettings Initializing the client
3 Constructor changed: env-var mode → PaperlessClient.from_env(); config mode → PaperlessClient.from_config(cfg) Initializing the client
4 request_api_version removed from constructor and PaperlessSettings Initializing the client
5 Replace reduce() with filter() - different call pattern Iteration and filtering
6 draft() renamed to create(); model shortcuts update(), delete(), save() removed - use service or dispatcher CRUD
7 Replace request_permissions = True with with_permissions() Permissions
8 Rename doc.get_download(), doc.get_metadata(), etc. - shortcuts are back Document convenience methods renamed
9 Note deletion: note.delete() → service call Document notes
10 generate_api_token() is now a module-level function, no longer a static class method Token generation
11 New: profile, trash, documents.history, share_links, documents.bulk_edit, bulk_edit_objects New resources
12 Rename four Paperless-prefixed exception classes; new DeletionError, DispatchError Error handling

Dependencies

Replace aiohttp and yarl with httpx:

dependencies = ["aiohttp", "yarl", "pypaperless"]
dependencies = ["httpx", "pypaperless"]

Initializing the client

Class renamed

The client class and settings class were renamed:

v5 v6
Paperless PaperlessClient
PaperlessConfig PaperlessSettings

Update all imports and type annotations accordingly.

Constructor signature changed

In v5, the constructor accepted url, token, and request_api_version. In v6 the constructor only accepts url, token, and client. Environment-variable and config-object modes are now explicit factory class methods.

import aiohttp
paperless = Paperless("http://localhost:8000", "mytoken")
paperless = Paperless("http://localhost:8000", "mytoken", session=my_session)
paperless = Paperless("http://localhost:8000", "mytoken", request_args={"ssl": False})
import httpx
from pypaperless import PaperlessClient

paperless = PaperlessClient("http://localhost:8000", "mytoken")
paperless = PaperlessClient("http://localhost:8000", "mytoken", client=my_httpx_client)

SSL / TLS customization

Pass a pre-configured httpx.AsyncClient to control TLS behaviour:

client = httpx.AsyncClient(verify=False)  # or verify="/path/to/cert.pem"
paperless = PaperlessClient("http://localhost:8000", "mytoken", client=client)

The url parameter no longer accepts yarl.URL objects - pass a plain string.

PaperlessSettings and factory class methods

v6 exposes PaperlessSettings (backed by pydantic-settings) and two factory class methods.

Config object - useful when you want to construct or validate settings in one place:

from pypaperless import Paperless, PaperlessConfig

cfg = PaperlessConfig(url="http://localhost:8000", token="mytoken")
paperless = Paperless(config=cfg)
from pypaperless import PaperlessClient, PaperlessSettings

cfg = PaperlessSettings(url="http://localhost:8000", token="mytoken")
paperless = PaperlessClient.from_config(cfg)

Environment variables - use the from_env() factory; PaperlessSettings reads the values automatically:

paperless = Paperless()  # zero-arg constructor read env vars
paperless = PaperlessClient.from_env()
Environment variable Maps to
PYPAPERLESS_URL URL of the Paperless-ngx instance
PYPAPERLESS_TOKEN API token

Note

PYPAPERLESS_REQUEST_API_VERSION was removed. API version is now negotiated automatically from the server's x-api-version response header.


Token generation

generate_api_token() is now a module-level function importable from pypaperless, no longer a static method on PaperlessClient. The optional custom-client argument was also renamed from session to client.

token = await Paperless.generate_api_token(url, username, password, session=my_session)
from pypaperless import generate_api_token

token = await generate_api_token(url, username, password, client=my_client)

Iteration and filtering

reduce() was replaced by filter(). The key difference: the context manager now yields the service object, so you iterate over ctx instead of reusing the outer service name.

async with paperless.documents.reduce(title__icontains="invoice"):
    async for doc in paperless.documents:
        print(doc.title)
async with paperless.documents.filter(title__icontains="invoice") as ctx:
    async for doc in ctx:
        print(doc.title)

pages() is available and returns enhanced Page objects with .items, .current_page, .last_page, and more.

async for page in paperless.documents.pages(page_size=25):
    for doc in page:
        print(doc.title)
async for page in paperless.documents.pages(page_size=25):
    print(f"Page {page.current_page} of {page.last_page}")
    for doc in page:
        print(doc.title)

You can also use the convenience helpers:

docs = await paperless.documents.as_list()
ids  = await paperless.documents.all()       # list of primary keys only
dmap = await paperless.documents.as_dict()   # {pk: Document}

CRUD

In v5, CRUD operations lived on model instances. v6 moves the canonical API to the service level. Model-level shortcuts (doc.update(), doc.delete(), draft.save()) have been removed.

New in v6: client-level dispatcher

Instead of calling the operation on a specific service, you can call it directly on the PaperlessClient instance. The dispatcher automatically routes to the correct service based on the model type — no need to know which service owns the model:

doc = await paperless.documents(42)
doc.title = "New Title"
await paperless.update(doc)   # routes to DocumentService.update()

await paperless.delete(doc)   # routes to DocumentService.delete()

draft = paperless.tags.create(name="urgent")
pk = await paperless.save(draft)  # routes to TagService.save()

This works for all dispatchable resources: documents, correspondents, document types, storage paths, tags, share links, and custom fields.

v6 provides two equivalent ways to perform CRUD:

  • Service-level — call the operation on the service that owns the model type.
  • Client-level dispatcher — call await paperless.update(model) / await paperless.delete(model) / await paperless.save(draft) directly on the client; the dispatcher resolves the responsible service automatically.

update()

doc = await paperless.documents(42)
doc.title = "New Title"
await doc.update()
doc = await paperless.documents(42)
doc.title = "New Title"
await paperless.documents.update(doc)
doc = await paperless.documents(42)
doc.title = "New Title"
await paperless.update(doc)  # resolves to DocumentService automatically

delete()

delete() no longer returns a boolean. It raises DeletionError on failure (or swallows it when silent_fail=True).

doc = await paperless.documents(42)
await doc.delete()
doc = await paperless.documents(42)
await paperless.documents.delete(doc)
# raises DeletionError on failure
doc = await paperless.documents(42)
await paperless.delete(doc)

This applies to every resource - correspondents, tags, custom fields, etc.

save() / create()

draft() was renamed to create(). The model-level draft.save() shortcut was removed; use the service or the dispatcher instead.

draft = paperless.documents.draft(document=raw_bytes, title="Invoice")
task_id = await draft.save()
draft = paperless.documents.create(document=raw_bytes, title="Invoice")
task_id = await paperless.documents.save(draft)
draft = paperless.documents.create(document=raw_bytes, title="Invoice")
task_id = await paperless.save(draft)

For all other resources (correspondents, tags, …):

draft = paperless.tags.draft(name="urgent")
pk = await draft.save()
draft = paperless.tags.create(name="urgent")
pk = await paperless.tags.save(draft)
draft = paperless.tags.create(name="urgent")
pk = await paperless.save(draft)

Permissions

The mutable request_permissions setter was replaced by a with_permissions() context manager. The flag is now automatically reset on exit.

paperless.documents.request_permissions = True
doc = await paperless.documents(42)
print(doc.owner, doc.permissions)
paperless.documents.request_permissions = False
async with paperless.documents.with_permissions() as ctx:
    doc = await ctx(42)
    print(doc.owner, doc.permissions)
    async for doc in ctx:
        print(doc.owner, doc.permissions)

Note

Unlike v5, with_permissions() resets it automatically on exit, even if an exception occurs.


Document convenience methods renamed

The get_* shortcut methods that existed in v5 on Document instances are fully removed in v6. All file and metadata access now goes through the service:

v5 (on model instance) v6
await doc.get_download() await paperless.documents.download(doc.id)
await doc.get_download(original=True) await paperless.documents.download(doc.id, original=True)
await doc.get_preview() await paperless.documents.preview(doc.id)
await doc.get_thumbnail() await paperless.documents.thumbnail(doc.id)
await doc.get_metadata() await paperless.documents.metadata(doc.id)
await doc.get_suggestions() await paperless.documents.suggestions(doc.id)
(not available) async for d in paperless.documents.more_like(doc.id):
(not available) await paperless.documents.email(doc.id, ...)

Sub-service shortcuts for notes, history and share links remain available via bound sub-service properties on the Document instance:

Access Equivalent
await doc.notes() await paperless.documents.notes(doc.id)
await doc.history() await paperless.documents.history(doc.id)
await doc.share_links() await paperless.documents.share_links(doc.id)

Document notes

Note deletion moved from the model to the service, consistent with the general CRUD pattern. The model-level note.delete() shortcut was removed.

notes = await paperless.documents.notes(42)
for note in notes:
    await note.delete()
notes = await paperless.documents.notes(42)
for note in notes:
    await paperless.documents.notes.delete(note)

The doc.notes property on Document instances still exposes a bound DocumentNoteService:

Creating a new note:

draft = paperless.documents.notes.draft(note="Checked.", document=42)
note_id, doc_id = await draft.save()
doc = await paperless.documents(42)
draft = doc.notes.create(note="Checked.")
note_id = await doc.notes.save(draft)

Note

When using doc.notes, the document pk is bound automatically - no need to pass document= to create().

draft = paperless.documents.notes.create(42, note="Checked.")
note_id = await paperless.documents.notes.save(draft)

Note

save() now returns only the new note id as int. In v5 it returned a (note_id, doc_id) tuple.


New resources

Six new services were added.

paperless.profile

Access the currently authenticated user's own profile:

profile = await paperless.profile()
print(profile.username, profile.email)

See Profile for details.

paperless.trash

Browse and manage soft-deleted documents:

async for doc in paperless.trash:
    print(doc.id, doc.title, doc.deleted_at)

await paperless.trash.restore([42, 43])
await paperless.trash.empty()

The Document.is_deleted property returns True for documents retrieved from the trash.

See Trash for details.

Create and manage publicly accessible share links for documents without requiring authentication. Supports full CRUD.

from pypaperless.models.share_links import ShareLinkFileVersion

draft = paperless.share_links.create()
draft.document = 42
draft.file_version = ShareLinkFileVersion.ARCHIVE
slug = await paperless.share_links.save(draft)
print(slug)  # "abc123xyz"

link = await paperless.share_links(8)
await paperless.share_links.delete(link)

Document model instances also expose a share_links() shortcut:

doc = await paperless.documents(42)
links = await doc.share_links()

See Share Links for details.

paperless.documents.history

A new document audit-log sub-service:

entries = await paperless.documents.history(42)
for entry in entries:
    print(entry.actor, entry.timestamp, entry.action)

paperless.documents.bulk_edit

A new sub-service for performing bulk operations across many documents in a single API call. All operations accept a list of document primary keys.

# Assign metadata to multiple documents at once
await paperless.documents.bulk_edit.set_correspondent([1, 2, 3], 5)
await paperless.documents.bulk_edit.set_document_type([1, 2], 3)
await paperless.documents.bulk_edit.set_storage_path([1, 2], 4)

# Tag operations
await paperless.documents.bulk_edit.add_tag([1, 2, 3], 7)
await paperless.documents.bulk_edit.remove_tag([1, 2, 3], 7)
await paperless.documents.bulk_edit.modify_tags([1, 2], add_tags=[5], remove_tags=[2])

# Custom fields
await paperless.documents.bulk_edit.modify_custom_fields(
    [1, 2],
    add_custom_fields={3: "open"},
    remove_custom_fields=[4],
)

# Permissions
from pypaperless.models.types import Permissions
await paperless.documents.bulk_edit.set_permissions(
    [1, 2, 3],
    owner=1,
    permissions=Permissions(view_users=[2, 3], change_users=[1]),
)

# Document operations
await paperless.documents.bulk_edit.delete([10, 11])      # move to trash
await paperless.documents.bulk_edit.reprocess([1, 2, 3])  # re-run OCR
await paperless.documents.bulk_edit.rotate([1, 2], 90)
await paperless.documents.bulk_edit.merge(
    [10, 11, 12],
    metadata_document_id=10,
    delete_originals=True,
)

Raises BulkEditError (a ResponseError subclass) when the API returns a non-OK result.

paperless.bulk_edit_objects

A new top-level service for setting permissions or deleting multiple non-document objects - tags, correspondents, document types, or storage paths - in a single call.

from pypaperless.models.types import Permissions

# Set permissions on several tags
await paperless.bulk_edit_objects.set_permissions(
    "tags",
    [1, 2, 3],
    owner=1,
    permissions=Permissions(view_users=[2], change_users=[1]),
)

# Permanently delete correspondents
await paperless.bulk_edit_objects.delete("correspondents", [4, 5])

Raises BulkEditError when the API returns a non-OK result. See Bulk Edit Objects for details.


Error handling

PaperlessConnectionError is now raised for httpx.ConnectError rather than aiohttp.ClientConnectorError. If you catch library-specific exceptions, update accordingly:

except aiohttp.ClientConnectorError:
    ...
except httpx.ConnectError:
    ...
except PaperlessConnectionError:
    ...

Tip

PaperlessConnectionError wraps httpx.ConnectError and works in both v5 and v6 - catching it is the most forward-compatible option.

Exception renames

Four exception classes lost their Paperless prefix to follow standard Python naming conventions. PaperlessConnectionError is the only exception kept as-is, because it would otherwise shadow Python's built-in ConnectionError.

v5 v6
PaperlessAuthError AuthError
PaperlessInvalidTokenError InvalidTokenError
PaperlessInactiveOrDeletedError InactiveOrDeletedError
PaperlessForbiddenError ForbiddenError
from pypaperless.exceptions import PaperlessAuthError, PaperlessForbiddenError

except PaperlessAuthError:
    ...
except PaperlessForbiddenError:
    ...
from pypaperless.exceptions import AuthError, ForbiddenError

except AuthError:
    ...
except ForbiddenError:
    ...

New exception base classes

v6 introduces intermediate base classes that you can use to catch whole groups of related errors:

Class Catches
InitializationError All session/transport errors (unchanged from v5)
ResponseError BadJsonResponseError, JsonResponseWithError, BulkEditError
DraftError DraftFieldRequiredError, DraftNotSupportedError
ResourceError DeletionError, ItemNotFoundError, PrimaryKeyRequiredError, TaskNotFoundError
DocumentError AsnRequestError, SendEmailError

New exceptions

Exception When raised
DeletionError delete() call receives a non-2xx HTTP response
DispatchError update() / delete() / save() called on an unregistered model type