This release is a pre-release and may not be stable for production use.
personio_client.py
An async Python client for the Personio API v2. It obtains access tokens and reads persons and employments.
Installation
pip install personio-client
Usage
import asyncio
from personio_client import PersonioClient
async def main() -> None:
async with PersonioClient(client_id="papi-...", client_secret="papi-...", app_id="MY_APP") as client:
async for person in client.iter_persons(status="ACTIVE"):
print(person.first_name, person.last_name, person.email)
if person.id is not None: # the spec declares no required fields
async for employment in client.iter_employments(person.id):
print(" ", employment.status, employment.employment_start_date)
if __name__ == "__main__":
asyncio.run(main())
Authentication
- The client authenticates with API credentials of Personio
(see Generate and manage API credentials).
They need the scope
personio:persons:read, for the persons and the employment endpoints. We recommend credentials that have only this scope. - The client obtains an access token when it sends its first request, and a new one shortly before the token expires
(tokens are valid for one day). By default, the token gets all scopes of the credentials; pass
scope=[...]to restrict it.obtain_access_token()requests a token explicitly, e.g. to check its scopes. - Pass
app_id(e.g."MY_APP") and, if you are an integration partner of Personio,partner_id. Personio strongly recommends these headers (X-Personio-App-ID,X-Personio-Partner-ID), so that it can support you with API issues.
Pagination
get_persons() and get_employments() return one page, a CursorPage with the items in data and the cursor of the next page in next_cursor
(None on the last page):
page = await client.get_persons(limit=50, updated_at_gt=datetime(2026, 1, 1, tzinfo=UTC))
while True:
for person in page.data:
...
if page.next_cursor is None:
break
page = await client.get_persons(limit=50, updated_at_gt=datetime(2026, 1, 1, tzinfo=UTC), cursor=page.next_cursor)
iter_persons() and iter_employments() do this for you: they request 50 items per page and resend the filters with each page.
If the API returns a cursor twice, they raise PersonioClientError instead of looping forever or returning a partial result.
Filters are keyword arguments named after the query parameters of the spec, with dots replaced by underscores
(e.g. updated_at_gt for updated_at.gt). Lists like id and email are sent as one comma-separated value.
Date-time filters take a timezone-aware datetime.datetime; a naive datetime raises ValueError.
Models
The models are generated from the OpenAPI specs of Personio with pydantic. Fields the API adds are ignored. Because the spec declares no required fields, every field is optional. To keep an unexpected value from breaking the parsing of a whole page, the models deviate from the specs in a few places:
- Enums are plain strings. Personio isn't consistent in their casing: the API sends the custom attribute types
in lower case (
string,date), while the spec listsSTRINGandDATE; the statuses are upper case (ACTIVE). Compare them case-insensitively. - E-mail addresses, IDs and links are plain strings.
- The
valueof a custom attribute is any JSON value (a string, a number, a boolean, a list, ...). - The employment dates (
employment_start_date,employment_end_date,probation_end_date,contract_end_date) aredatetime.datevalues, as in the write specs of Personio (the read spec types them as plain strings).
Rate Limits
Personio doesn't document the rate limits of these endpoints.
Its responses carry the headers of a token bucket (x-ratelimit-burst-capacity, x-ratelimit-replenish-rate, x-ratelimit-remaining);
in our test, the bucket held 100 requests.
The client retries a request answered with 429 Too Many Requests up to max_retries times (default 3),
waiting as long as the Retry-After header says, otherwise 1 s, 2 s and 4 s.
Then it raises PersonioRateLimitError. Pass max_retries=0 to disable the retries.
Reading the employments of all persons takes one request per person, so don't send too many requests at once,
e.g. by limiting them with an asyncio.Semaphore:
semaphore = asyncio.Semaphore(5)
async def employments_of(client: PersonioClient, person_id: str) -> list[Employment]:
async with semaphore:
return [employment async for employment in client.iter_employments(person_id)]
Error Handling
from personio_client import PersonioAPIError, PersonioAuthenticationError, PersonioRateLimitError
try:
person = await client.get_person("3003")
except PersonioAuthenticationError as error:
print(f"Authentication failed: {error.message}")
except PersonioRateLimitError as error:
print(f"Rate limit exceeded; retry after {error.retry_after} seconds")
except PersonioAPIError as error:
print(f"API error {error.status_code}: {error.message} (trace ID: {error.trace_id})")
PersonioAuthenticationError and PersonioRateLimitError are subclasses of PersonioAPIError, which is a subclass of PersonioClientError.
Personio support asks for the trace_id of a failed request.
API Coverage
The client covers the Personio API v2 as described by the specs in openapi/v2/,
synced from developer.personio.de/openapi on 2026-09-30.
The tables are grouped by the tags of the specs.
Every method wraps exactly one operation, except iter_persons() and iter_employments(), which walk through all pages.
5 of 6 operations are implemented.
Authentication
| Endpoint | Method |
|---|---|
POST /v2/auth/revoke |
not implemented |
POST /v2/auth/token |
obtain_access_token() |
Employments
| Endpoint | Method |
|---|---|
GET /v2/persons/{person_id}/employments |
get_employments() |
GET /v2/persons/{person_id}/employments/{id} |
get_employment() |
Persons
| Endpoint | Method |
|---|---|
GET /v2/persons |
get_persons() |
GET /v2/persons/{id} |
get_person() |
Related Packages
Other Python packages for Personio have similar names.
To avoid confusion, this is how they differ from personio-client (as of September 2026):
| Package (import name) | What it is |
|---|---|
personio-api-client (personio_api_client) |
A synchronous client for the Personio API v1 (employees, time-offs). Its GitHub repository also contains a v2 client for projects and attendance periods. It has a class PersonioClient, too. |
personio-client-api (personio_client_api) |
A minimal synchronous client for the Personio API v1 (employees). |
personio-py (personio_py) |
A synchronous client for the Personio API v1 (employees, attendances, absences, projects). |
dlt-source-personio (dlt_source_personio) |
A dlt source that loads persons and employments into a data pipeline; not a client library. |
personio-client (import name personio_client) is a client library for the persons and employments endpoints of the Personio API v2,
which none of the packages above offers.
It is async (aiohttp), and its models are generated from the official OpenAPI specs of Personio,
like the decidalo client of Hochfrequenz.
Because of these different goals, we build this client instead of contributing to personio-api-client, which comes closest.
We may approach its maintainer, e.g. to link the two projects to each other.
Development
Clone the repository and install the development environment:
git clone https://github.com/Hochfrequenz/personio_client.py.git
cd personio_client.py
uv sync --group dev
uv run pytest
To sync the client with the current API, download the specs and regenerate the models from them:
uv run --group codegen python scripts/generate_models.py --download
Review the changes of the specs with git diff openapi/, then run the tests:
unittests/test_models.py checks the models against the specs, and unittests/test_api_coverage.py checks the API Coverage section of this README against the specs and the client.
To print the expected content of that section, run:
uv run python unittests/test_api_coverage.py
The design decisions are documented in the implementation plan. For detailed information on the development setup (uv configuration, IDE setup, etc.), see the Hochfrequenz Python Template Repository.
License
This project is licensed under the MIT License - see the LICENSE file for details.
Metadata
Release files for personio_client 0.1.0a1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| personio_client-0.1.0a1.tar.gz | 198.0 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| personio_client-0.1.0a1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 218.5 kB
Release files / personio_client-0.1.0a1.tar.gz
| Download URL | personio_client-0.1.0a1.tar.gz |
|---|---|
| Size | 198.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
bfb60b0389508abb117938b0aa151c9c34f297b8b9fd6f242a5d76702b8265b1
|
|
BLAKE2b-256 checksum How to use checksums |
f6a09d29020e712cee976faceef5730c09b27b7716b34363e530a59ad0b5f0b8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.23 {"installer":{"name":"uv","version":"0.12.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / personio_client-0.1.0a1-py3-none-any.whl
| Download URL | personio_client-0.1.0a1-py3-none-any.whl |
|---|---|
| Size | 20.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
11f925b786399067b660ac4ba87bd26ba71b09012a5bb21602eef54bf62ca5dc
|
|
BLAKE2b-256 checksum How to use checksums |
506d5fc3f9eef8e508317fe5a7156e72d7308ada8b0a72c8b5589d3e8a0532c6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.23 {"installer":{"name":"uv","version":"0.12.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Security for personio_client 0.1.0a1
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.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.