Python

Software Mile builds in Python – the language of choice for automation, data work, back-end services, and the glue that connects systems. When a job involves data or scripting, Python is usually the shortest path.

Where Python Wins

  • Back-end services and APIs with Django and FastAPI
  • Automation and scripting that replace manual, repetitive work
  • Data processing, pipelines, and analysis workloads
  • The integration layer between systems, and the foundation for AI work at our sister practice SoftwareDepo

Python is fast to build in and strong for data and automation. Tell us the problem – if it involves data or scripting, Python is often the answer.

Django and FastAPI Answer Different Questions

The choice comes down to how much of your application is HTML that people look at and how much is JSON that other systems consume. Django arrives with an ORM, migrations, an admin interface, authentication, sessions and form handling already wired together. For a business application with roles, records and a lot of screens, that head start usually matters more than raw request throughput.

FastAPI fits better when the service mostly answers other services under concurrent I/O, and when you want request and response schemas validated and documented from type hints. The common mistake is picking FastAPI for something that was really a Django application, then rebuilding admin screens, user management and migration tooling by hand. Flask sits between the two for small services that need routing and little else. Django REST Framework is not a third option here: it is a Django extension, so reaching for it means you have already chosen Django and want a structured way to expose JSON from it.

When Is Python the Wrong Answer?

Sustained CPU-bound work inside a single process is the clearest case. The global interpreter lock has long kept pure Python computation on one core at a time, and although free-threaded builds are now a supported option in recent CPython releases, many production stacks and C extensions have not caught up with them. Until that settles, spreading heavy computation still means multiple processes, native libraries doing the work outside the interpreter, or moving the job to a different runtime altogether.

The organizational answer matters as much as the technical one. If your team writes C# and operates .NET services, one Python service means a second toolchain, second deployment habits and a second on-call skill set. Sometimes a data or machine learning workload justifies that. Often the honest recommendation is to build it in the language your team already supports.

The Script That Quietly Became Production

It is a familiar shape. Something starts as one person’s script, it works, so it keeps running, and eventually a piece of the business depends on a file nobody else has read. The code itself is rarely the problem. The conditions around it are, and they are recognizable enough to list.

Putting it on a proper footing is mostly unglamorous work: a repository, a lockfile, a container image, structured logging, alerting on failure, and a few tests that assert the output is still what it was. The logic often survives untouched, which keeps the scope well short of what the word rewrite implies.

  • It runs on one person’s machine, or a server nobody documented.
  • Credentials sit in the source file or in an environment nobody controls.
  • There is no logging, so failures are discovered by their consequences.
  • No test proves the output is still right after a change.

Sizing Infrastructure Around a Python Service

A web application needs an ASGI or WSGI server in front of it, somewhere to run background jobs and somewhere to keep state. Past that, the honest answer depends on volume. A nightly job handling a few thousand records runs fine on one virtual machine with a scheduler, and reaching for a cluster there adds cost and operational surface without adding reliability.

Where scale justifies it, containers, queue workers and managed databases are worth the setup, and that hosting side is what our cloud migration and support work covers. An architecture that needs a platform team you do not have is not a saving, whatever it looks like on a diagram.

Dynamic Typing Puts the Discipline on You

The compiler catches almost nothing, so the discipline has to come from somewhere else: type hints checked by a static analyzer, a pinned lockfile, a formatter and linter enforced in CI, and tests covering the paths that matter. That discipline is the difference between a codebase you can hand to a new developer and one you can only replace.

Two years is long enough for the original author to have moved on and for every framework in the file to have shipped several versions. What survives that is boring structure: modules organized around what they do, dependencies pinned so an install today matches an install last spring, and a README that says how to run the tests. Adding type hints and tests to a working Python application is slower to describe than a rewrite and usually the better first move.

Packaging Is Where Python Deployments Fail First

Python code is portable. Its dependencies are not. pip installs a prebuilt wheel when one matches the target platform and falls back to compiling from source when none does, and the build then needs a toolchain and header files that a slim base image deliberately does not carry. Wheel filenames spell the constraint out: a file tagged cp312-cp312-manylinux_2_28_x86_64 will not install on 3.11, on musl, or on arm64.

Two mismatches account for most of it. manylinux wheels are compiled against glibc, so an Alpine image matches nothing and rebuilds every C extension from source; the musllinux tag exists but coverage is thinner, and moving to a Debian slim base is usually less work than maintaining a build stage. The other is architecture. An image built on an Apple Silicon laptop is arm64 by default and will not run on an x86_64 host unless the build sets the target platform on purpose.

The failures sort cleanly by their error text, which is worth knowing before anyone starts changing pins at random.

  • "error: command 'gcc' failed" or "fatal error: Python.h: No such file or directory" during pip install: the package is being compiled rather than installed from a wheel.
  • "exec format error" when the container starts, or a container that runs but at a fraction of expected speed under emulation: the image architecture does not match the host.
  • pip reports that it is looking at multiple versions of a package and that this could take a while, then sits for several minutes: the resolver is backtracking through old releases because two requirements have no overlapping compatible version. Read the conflict it eventually prints instead of loosening pins one at a time.
  • "numpy.dtype size changed, may indicate binary incompatibility", or a message that a module compiled with NumPy 1.x cannot run under 2.x: the extension was built against a different NumPy C ABI, which changed at 2.0. Rebuild the extension against the installed version, or cap NumPy below 2 until you can.
  • "error: externally-managed-environment": PEP 668, a distribution-managed interpreter refusing installs into the system site-packages. Create a virtual environment; --break-system-packages is not the fix, it is the thing PEP 668 was written to prevent.

The Version You Can Actually Target

CPython ships a feature release each October and supports it for roughly five years, about two of bug fixes and three of security patches only. 3.8 reached end of life in October 2024 and 3.9 followed a year later, so an application still pinned to either is running an interpreter that receives nothing at all, including for vulnerabilities in the standard library.

What blocks an upgrade is rarely the language. It is the slowest compiled dependency: until a package publishes wheels for the new interpreter, moving means building it yourself or waiting for the maintainer. Standard library removals are the other trap. 3.12 dropped distutils, so build scripts that import it fail unless setuptools supplies the shim, and several long-deprecated modules went with it.

Pin the minor version everywhere it is recorded: the image tag as python:3.12-slim rather than python:3 or python:latest, requires-python in the project metadata, and the same version in CI. A floating tag means the interpreter can change underneath a build that nobody touched, and the resulting failure looks like a code regression rather than a base image update.

One Blocking Call Stalls the Whole Event Loop

Async Python gets its concurrency from a single thread running an event loop, so one blocking call inside a coroutine stops every other request in that process for as long as it takes. The signature is distinctive: latency rises across all endpoints at once, including ones that do no work, rather than on the slow path alone. Timing the handler in isolation shows nothing, because the time is spent waiting in the queue rather than in the function.

The usual causes are a synchronous database driver, an HTTP client such as requests instead of an async one, plain file reads, and CPU-heavy serialization. Either move the call off the loop with asyncio.to_thread, or do not declare the handler async in the first place: in FastAPI a path operation written with def runs in a worker threadpool, which is the right answer for blocking code and far cheaper than rewriting it. Django's ORM is synchronous underneath and raises SynchronousOnlyOperation when called directly from async context, so ORM work there belongs behind sync_to_async or in an ordinary sync view.

Worker counts and pool sizes multiply, and the arithmetic is easy to skip. Each gunicorn or uvicorn worker is a separate process holding its own connection pool, so eight workers with a pool of ten open eighty database connections when saturated, against a PostgreSQL default max_connections of one hundred. Size the pool from the worker count, or put a pooler such as PgBouncer in front and let the application ask for more than the database would otherwise allow.

Background Jobs Run at Least Once, Not Exactly Once

Task queues deliver at least once by design. A worker that completes the work and then dies before acknowledging leaves the message on the broker, and it is handed to another worker. Any task with an effect outside the process, sending mail, calling a payment API, writing a file, therefore needs a key it can check before acting, or the retry that was meant to add reliability sends the message twice.

Two conventions remove most of the remaining debugging. Pass identifiers rather than objects: a serialized model instance is a snapshot of a row that may have changed by the time a worker picks it up, and a primary key forces a fresh read. And run exactly one scheduler. A beat process started in two containers produces two of every periodic task, which stays invisible until something that must run once a night runs twice.

Long tasks interact badly with broker defaults. Redis and SQS style transports redeliver a message whose visibility timeout expires while the task is still running, so a job that legitimately takes an hour ends up running alongside a second copy of itself. Check the actual default for the transport you use rather than assuming it exceeds your worst case, and prefer splitting the work into pieces that finish quickly over raising the timeout indefinitely.

When the Data Outgrows pandas

pandas holds the entire frame in memory, and peak usage during a merge, sort or groupby is a multiple of the input rather than equal to it. Text is the worst case: read without dtypes specified, a string column becomes Python objects with per-value overhead, so a CSV of a few gigabytes can need several times that in RAM before any computation starts. Declaring dtypes on read, using the categorical type for low-cardinality strings, and storing Parquet instead of CSV so only the needed columns are loaded will often keep a job inside the machine it already has.

Past that point the answer is usually not a larger instance. DuckDB runs SQL directly against Parquet files and reads only the columns and row groups a query touches, with no load step at all. Polars builds a lazy plan and can stream a dataset larger than memory. And when the data already lives in PostgreSQL, the aggregation generally belongs in the query rather than in a Python process that pulls every row across the network in order to count them.

Frequently Asked Questions

Does adding Python mean replacing the services we already run?

No. It usually sits alongside them. A Python service joins an existing estate across a network boundary, as an HTTP API, a queue consumer, or a scheduled job reading a shared database, so it does not have to share a build system, runtime or release train with a .NET or Java application. In-process interoperability between the two is possible but rarely worth what it costs, and the network boundary is what keeps the second toolchain contained to one deployable.

What has to already exist before a Python service can be deployed?

A registry to hold the built image, a secrets store or platform-provided equivalent so credentials are not sitting in the repository, outbound network rules for every host the service calls, and DNS plus a certificate if anything reaches it from outside. None of that is specific to Python; it is the same list any deployed service needs. The slow items are almost always the firewall change requests and the credential issuance, so raising those early is worth more than any improvement to the build.

Which Python version should a new project target?

The newest release that all your dependencies publish wheels for, which in the first months after an October release usually means the version before it. Pin it exactly, as 3.12 rather than 3 or latest, in the image tag, the project metadata and CI. Anything still on 3.8 or 3.9 is past end of life and receives no security patches, which makes moving off it a maintenance obligation rather than a preference.

We have a script somebody wrote years ago. Can you take it over?

Usually, and what decides it is reproducibility rather than code quality. If the thing can be run from a clean checkout on a machine that is not the author's, and someone can say what the output should be, the rest is mechanical. Where nobody can state what correct looks like, the workable route is to run the existing version and the replacement against the same inputs for a period and compare, because without a stated expectation there is no test to write.

Does it have to run in a container?

No. A virtual environment with a systemd service or timer is a legitimate deployment for a single job on a single server, and it is less machinery for whoever inherits it. Containers earn their cost when there is more than one target, when the interpreter and the system libraries need to be pinned together, or when the hosting platform expects an image. The failure they remove is the one where an OS package upgrade changes behavior and nobody connects it to the deploy that happened weeks earlier.

Can Python run in the browser or ship as a desktop application?

Not comfortably, in either case. Browsers execute JavaScript and WebAssembly, so running Python there means shipping a compiled interpreter such as Pyodide to the client, a multi-megabyte download before any of your code executes, which is defensible for notebook-style tools and little else. Desktop distribution through PyInstaller and similar tools bundles the interpreter into an executable and does work, but signing, size and startup time make it a weaker fit than a native toolkit when the desktop application is the product itself. Python's ground is the server, the pipeline and the command line.

What do you need from us to start?

Access to the systems the code has to talk to, including a non-production copy of representative data where the work touches data. A named person who can decide what the output should be when the code and the documentation disagree, since that question comes up in almost every integration. And a decision about who operates the result afterward, which changes the build itself: something your team will run has to fit your existing deployment and monitoring conventions, and that is a different set of choices from something we continue to operate.