DEV Community

Cover image for How to Build and Publish a Python Package to PyPI (With a Real Project)
Developer Service
Developer Service

Posted on • Originally published at developer-service.blog

How to Build and Publish a Python Package to PyPI (With a Real Project)

You've written a useful Python utility, a helper for parsing files, a small data tool, or a class you keep copying between projects. At some point you think: I wish I could just pip install this. You can, and it's simpler than it looks.

Packaging your code and publishing it to PyPI (the Python Package Index) forces you to treat your code as a product: clear interfaces, versioning, and documentation others can follow. A published package is also a credibility signal, you didn't just write code, you shipped it.

In this tutorial we'll build tinyfiledb, a lightweight file-based database that stores, retrieves, updates, and deletes records in a local JSON file. No server, no migrations, no dependencies. It's small by design so we can focus on the packaging workflow.

By the end you will have:

  • A properly structured Python package
  • A pyproject.toml configured and ready
  • A package built and published to PyPI
  • A working pip install tinyfiledb you can share

The Project: A Tiny File Database

tinyfiledb is a minimalist file-based database: no server, no ORM. It stores records as JSON and exposes a simple Python API:

  • insert(record) - add a record, get back an auto-generated ID
  • get(id) - retrieve one record by ID
  • all() - return every record
  • update(id, data) - merge data into an existing record
  • delete(id) - remove a record

Project Structure

This is the project structure you will use:

tinyfiledb/
├── tinyfiledb/
│   ├── __init__.py
│   └── db.py
├── pyproject.toml
└── README.md
Enter fullscreen mode Exit fullscreen mode

tinyfiledb/db.py

The main project file that contains the tiny database implementation:

import json
import uuid
from pathlib import Path


class FileDB:
    def __init__(self, filepath: str = "db.json"):
        self.path = Path(filepath)
        if not self.path.exists():
            self._write({})

    def _read(self) -> dict:
        with open(self.path, "r") as f:
            return json.load(f)

    def _write(self, data: dict):
        with open(self.path, "w") as f:
            json.dump(data, f, indent=2)

    def insert(self, record: dict) -> str:
        data = self._read()
        record_id = str(uuid.uuid4())[:8]
        data[record_id] = record
        self._write(data)
        return record_id

    def get(self, record_id: str) -> dict | None:
        return self._read().get(record_id)

    def all(self) -> dict:
        return self._read()

    def update(self, record_id: str, new_data: dict) -> bool:
        data = self._read()
        if record_id not in data:
            return False
        data[record_id].update(new_data)
        self._write(data)
        return True

    def delete(self, record_id: str) -> bool:
        data = self._read()
        if record_id not in data:
            return False
        del data[record_id]
        self._write(data)
        return True
Enter fullscreen mode Exit fullscreen mode

Supports full CRUD operations - insert() returns an auto-generated 8-character UUID key, get() and all() read from disk on every call, and update() merges new fields into an existing record rather than replacing it. The file is created automatically on initialization if it doesn't exist.

tinyfiledb/__init__.py

Expose only what users need:

from .db import FileDB

__all__ = ["FileDB"]
Enter fullscreen mode Exit fullscreen mode

Quick Usage

You can test it with this quick usage example:

from tinyfiledb import FileDB

db = FileDB("mydata.json")
user_id = db.insert({"name": "Alice", "role": "admin"})
print(db.get(user_id))   # {'name': 'Alice', 'role': 'admin'}
db.update(user_id, {"role": "superadmin"})
print(db.all())
db.delete(user_id)
Enter fullscreen mode Exit fullscreen mode

Should return:

{'name': 'Alice', 'role': 'admin'}
{'daf0f9fb': {'name': 'Alice', 'role': 'superadmin'}}
Enter fullscreen mode Exit fullscreen mode

Roughly 50 lines of code - enough to be useful, small enough to keep the focus on packaging.


Structuring Your Package

Your project must be laid out so Python's tooling can find and install the package. There are two common layouts:

  • src/ layout - package lives under src/tinyfiledb/. Helps avoid importing the local uninstalled copy during tests.

  • Flat layout - package sits directly in the project root as tinyfiledb/. Simpler and what we'll use.

The project uses the flat layout:

tinyfiledb/
├── tinyfiledb/
│   ├── __init__.py
│   └── db.py
├── pyproject.toml
└── README.md
Enter fullscreen mode Exit fullscreen mode

Your __init__.py is the package's public face.

Keep it minimal: re-export the main API and set __all__ so the public surface is explicit.

We already did that above.


Writing pyproject.toml

setup.py required running Python just to read metadata. pyproject.toml is a static config file, no code execution. Every modern build tool (Hatchling, Flit, setuptools) and PyPI expect it.

For new packages, it's the only config you need. Create pyproject.toml in the project root:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "tinyfiledb"
version = "0.1.0"
description = "A lightweight file-based database for Python projects."
readme = "README.md"
license = { text = "MIT" }
authors = [
  { name = "Your Name", email = "you@example.com" }
]
requires-python = ">=3.10"
dependencies = []
Enter fullscreen mode Exit fullscreen mode

What each part does:

  • [build-system] - Tells Python how to build the package. Hatchling is fast and needs no extra config for a simple project.
  • name - What users type in pip install. Must be unique on PyPI; use hyphens by convention.
  • version - Semantic versioning; start at 0.1.0.
  • description - One-line summary on PyPI; keep it short.
  • readme - PyPI uses this for the project description.
  • license - e.g. { text = "MIT" } or reference a LICENSE file.
  • authors - Name/email; shown on PyPI.
  • requires-python - We use dict | None in code, so >=3.10.
  • dependencies - Runtime dependencies; empty for tinyfiledb.

Building the Distribution

A distribution is a versioned snapshot of your package that can be uploaded and installed. Python uses two formats; you'll produce both.

Install the build tool

pip install build
Enter fullscreen mode Exit fullscreen mode

Run the build

From the directory that contains pyproject.toml:

python -m build
Enter fullscreen mode Exit fullscreen mode

You'll get:

* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
  - hatchling
* Getting build dependencies for sdist...
* Building sdist...
* Building wheel from sdist
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
  - hatchling
* Getting build dependencies for wheel...
* Building wheel...
Successfully built tinyfiledb-0.1.0.tar.gz and tinyfiledb-0.1.0-py3-none-any.whl
Enter fullscreen mode Exit fullscreen mode

A dist/ folder will appear:

  • .tar.gz - Source distribution (sdist): raw source + metadata. Pip can build from it when no wheel is available.
  • .whl - Wheel: pre-built; pip installs it directly with no build step.

Publishing to PyPI

Use TestPyPI first.

PyPI does not allow re-uploading the same version. If you push 0.1.0 with a mistake, you must release 0.1.1.

TestPyPI is a sandbox - upload and fix without burning versions.

Install twine (upload tool):

pip install twine
Enter fullscreen mode Exit fullscreen mode

TestPyPI

First make sure your account and token are ready:

  • Account - test.pypi.org/account/register. Verify your email.
  • 2FA - PyPI requires two-factor authentication for uploads. Enable it under Account Settings before creating a token.
  • API token - Account Settings → API Tokens → Add. Name it (e.g. tinyfiledb-upload), copy the token (starts with pypi-); you won't see it again.

Upload:

twine upload --repository testpypi dist/*
Enter fullscreen mode Exit fullscreen mode

When prompted: username __token__, password = your token (literally __token__, not a placeholder).

Uploading distributions to https://test.pypi.org/legacy/
WARNING  This environment is not supported for trusted publishing
Enter your API token: 
Uploading tinyfiledb-0.1.0-py3-none-any.whl
100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 4.8/4.8 kB • 00:01 • ?
Uploading tinyfiledb-0.1.0.tar.gz
100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 4.4/4.4 kB • 00:00 • ?

View at:
https://test.pypi.org/project/tinyfiledb/0.1.0/
Enter fullscreen mode Exit fullscreen mode

Test install in a fresh venv:

python -m venv test-env
# Windows: test-env\Scripts\activate
# macOS/Linux: source test-env/bin/activate

pip install --index-url https://test.pypi.org/simple/ tinyfiledb
Enter fullscreen mode Exit fullscreen mode

Then:

python -c "from tinyfiledb import FileDB; db = FileDB('t.json'); print(db.insert({'x': 1}))"
Enter fullscreen mode Exit fullscreen mode

If that works, you're ready for PyPI.

PyPI (production)

Make sure your account and token are ready:

  • Account - pypi.org/account/register. Verify email.
  • 2FA - PyPI requires two-factor authentication for uploads. Enable it under Account Settings before creating a token.
  • API token - Account Settings → API Tokens → Add. Use a project-scoped token after the first upload if you prefer.

Upload:

twine upload dist/*
Enter fullscreen mode Exit fullscreen mode

Username: __token__, password: your PyPI token. After a successful upload you'll get a project URL. Your package is live.

Uploading distributions to https://upload.pypi.org/legacy/
WARNING  This environment is not supported for trusted publishing
Enter your API token: 
Uploading tinyfiledb-0.1.0-py3-none-any.whl
100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 4.8/4.8 kB • 00:00 • ?
Uploading tinyfiledb-0.1.0.tar.gz
100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 4.4/4.4 kB • 00:00 • ?

View at:
https://pypi.org/project/tinyfiledb/0.1.0/
Enter fullscreen mode Exit fullscreen mode

Anyone can install it with:

pip install tinyfiledb
Enter fullscreen mode Exit fullscreen mode

Installing and Using Your Package

Verify in a clean environment (no local copy of the code):

mkdir demo && cd demo
python -m venv env
# Windows: env\Scripts\activate
# macOS/Linux: source env/bin/activate

pip install tinyfiledb
Enter fullscreen mode Exit fullscreen mode

Check metadata:

pip show tinyfiledb
Enter fullscreen mode Exit fullscreen mode

Will show:

Name: tinyfiledb
Version: 0.1.0
Summary: A lightweight file-based database for Python projects.
Home-page:
Author:
Author-email: Your Name <you@example.com>
License: MIT
Location: D:\GitHub\tinyfiledb\demo\env\Lib\site-packages
Requires:
Required-by:
Enter fullscreen mode Exit fullscreen mode

You can do a quick test:

from tinyfiledb import FileDB

db = FileDB("tasks.json")
id1 = db.insert({"title": "Write blog post", "done": False})
id2 = db.insert({"title": "Publish to PyPI", "done": True})
print(db.get(id1))
print(db.all())
db.update(id1, {"done": True})
db.delete(id2)
Enter fullscreen mode Exit fullscreen mode

Which returns:

{'title': 'Write blog post', 'done': False}
{'4d62fb2a': {'title': 'Write blog post', 'done': False}, '0f43c5be': {'title': 'Publish to PyPI', 'done': True}}
Enter fullscreen mode Exit fullscreen mode

After running, open tasks.json - you'll see human-readable JSON:

{
  "4d62fb2a": {
    "title": "Write blog post",
    "done": true
  }
}
Enter fullscreen mode Exit fullscreen mode

That's the whole flow: from a folder on your machine to a package anyone can install with one command.

Full source code available at:

tinyfiledb

A lightweight file-based database for Python projects. No server, no migrations, no dependencies — just a local JSON file and a simple API.

If this project was useful to you, consider donating to support the Developer Service Blog: https://buy.stripe.com/bIYdTrggi5lZamkdQW

Install

pip install tinyfiledb
Enter fullscreen mode Exit fullscreen mode

Usage

from tinyfiledb import FileDB

db = FileDB("mydata.json")

# Insert a record
user_id = db.insert({"name": "Alice", "role": "admin"})

# Get, update, list, delete
print(db.get(user_id))
db.update(user_id, {"role": "superadmin"})
print(db.all())
db.delete(user_id)
Enter fullscreen mode Exit fullscreen mode

API

  • insert(record) — add a record, get back an auto-generated ID
  • get(id) — retrieve one record by ID (returns None if not found)
  • all() — return every record as a dict
  • update(id, data) — merge data into an existing record; returns…





Conclusion

You built a real Python package: a small file-based database with a clear API, proper layout, and pyproject.toml.

You built distributions with python -m build, tested on TestPyPI with twine, then published to PyPI. The same workflow applies to any project you want to share - CLI tools, utilities, libraries.

The best first package is often something you've already written, a module you've copied between projects or a script you've shared. Add a pyproject.toml, a README, and a few minutes of setup, and it can be installable by anyone.

The ecosystem is built on packages that started that way. Yours doesn't need to be big; it just needs to exist.

Next steps worth exploring: semantic versioning, GitHub Actions for releases, pytest for tests, and project-scoped API tokens on PyPI.


Want to Go Deeper?

This article covers the full publish workflow, but there's a lot more ground to cover, structuring larger packages, writing a test suite, automating releases, handling versioning properly, and building a package others actually want to use.

If you want all of that in one place, pip install yours is the companion booklet to this tutorial. It picks up exactly where this article ends and walks you through everything it takes to build, maintain, and grow a Python package worth installing.

Now go ship something.


Follow me on Twitter: https://twitter.com/DevAsService

Follow me on Instagram: https://www.instagram.com/devasservice/

Follow me on TikTok: https://www.tiktok.com/@devasservice

Follow me on YouTube: https://www.youtube.com/@DevAsService

Top comments (0)