10.03.2026 16:22
Lock the Ghost
In the software world, “remove” is not equal to "gone." This is crystal clear. There is always a good reason for that, but even the best reason does not have to be intuitive or expected by the users. Let’s take a short trip through how Python Package Index handles removals and how we can lock the ghost in an uv.lock file – forever!
PyPI Package Lifecycle
Historically, package lifecycle in PyPI was a binary state: the package existed or not. This was later extended when the “quarantine” status was introduced [1] to temporarily block resolving packages during security investigations. Here it is also important to mention that when a package is removed by PyPI administration after deeming it malicious, the project name is routinely blocked from being reclaimed. This is not the case for packages removed by their owners, and it leads to a threat of taking over the name of a removed package, described by JFrog [3]. Package owners can now prevent this by archiving a project instead of removing it [4].
This organic growth of the package lifecycle was finally deeply discussed and formalized last year in PEP 792 [5], resulting in four package status markers: active, archived, quarantined, and deprecated [6], in addition to the package removal. Moreover, one can also play with the specific releases, which could be removed or yanked (“soft” removal intended for not breaking pinned versions [7]).
Locking dependencies
Managing dependencies is always a cry-triggering topic. The holy grail is achieving reproducible environments and builds. The classic Python approach is a simple lock file with just version pinning, represented by pip-tools [8], which could also be used in integrity-checking mode with hash validation. The step forward is more complex lock files, probably best known from the NPM world. Such a file extends standard version pinning with additional information, including hashes, filenames, and machine-readable dependency trees. They solve many problems but also introduce new issues. Who reviews all the automated package-lock.json changes [9]?
In Python, we have at least four manager-specific implementations: uv.lock, Pipfile.lock, poetry.lock, pdm.lock. Last year also brought an official pylock.toml [10].
The Ghost and the uv.lock File
Now, let’s do an experiment. I created a package in the test.pypi.org and added it as a dependency to a project using uv. Thereafter, the uv.lock file was generated, and the package was removed from the index. Finally, I attempted to install the project in a fresh virtualenv. How it went:

Commands executed after removing the dependency package. Note that the additional index URL was declared in the pyproject.toml.
And to confirm:

We found the ghost!
As you can see, at first (1), uv was unable to install the dependencies using the standard pip-compatible interface – obvious, as the package didn’t exist at all. But uv did not complain once I used the “uv sync” command, leveraging the lock file (2) and successfully installed the “ghost” package. In both cases, uv was instructed not to use local cache. As such, we got the dependency installed – the dependency that has already been removed from the upstream index!
How is it possible?
If you look at the uv.lock, you will see something like this:

The uv.lock used in the demonstration
If you look closely, you could see in lines 20 and 22 URLs directly to the dependency distribution files. They actually contain the package code. The philosophy behind uv is to be an extremely fast package manager. In this spirit, the uv.lock file contains everything needed to download and install dependencies without having to consult the index API anymore.
But I removed the package, didn’t I? Well, I did, and it means the package is gone from the index. You can no longer see anything under https://test.pypi.org/project/the-ghost/ However, PyPI does not remove the underlying distribution files. And if you have the link, you can still access it – and so did uv in this example!
I tested this behavior on four different Python package managers supporting lock files. Only uv stores the direct URLs and omits querying the index during installation. My experiment’s code is available in a repository [11].
The Malicious Ghost
In most cases, this behavior does not need to be a big problem (it even may be a bit helpful). However, consider the malicious packages, for example, used in the North Korean fake recruitment tasks, which are often GitHub repositories with projects referencing malicious dependencies from NPM or PyPI [12]. Such packages could be quickly removed from PyPI, but the related GitHub repositories are not always identified. If the threat actor uses different Git hosting, the situation is even more complex, as they tend to have limited code search functionality. If the malicious repository uses uv.lock to pin dependencies, the malicious code stays active even after the removal from the PyPI!
Moreover, we all still have the xz story in mind [13]. One of the reasons it almost succeeded was hiding malicious code parts in the test binary files. No one can effectively review binary files in every change, and so it is with all lock files as well: they are big, auto-generated, and even hidden in the review flow of git services like GitHub or GitLab. What keeps you from including only there a malicious dependency? This scenario was described by Snyk a few years ago in the NPM example [9]. Now, given that PyPI leaves the distribution files behind, you can try a trick: upload and immediately remove the package from the registry, before the security vendors even notice its existence. This way, you can significantly slow down the detection by avoiding the automatic analysis. And the uv.lock still lets you install the malicious dependency. If you use a different manager, just point it to the file URL directly – a bit more suspicious to see, but still easy to miss in review.
This is not an entirely theoretical scenario; I do have examples of Python packages put in the PyPI’s quarantine so quickly that some good vendors haven't noticed they ever existed.
Versions are not idempotent in PyPI
...but distribution files are.
There is also one more interesting edge case related to removing packages from PyPI. As I already mentioned, if the package was removed, the name could be reused by another user. So was the case of “umap”, which was removed by the original author and later taken over with a name hijack attempt [14]. Such a scenario was already covered by JFrog [3]. The weird thing? The new owner uploaded the package with the same version number as the previous one. How?
A Python package can have multiple distribution files, e.g. with the native module compiled for different architectures or Python versions. The most typical case is to have a source distribution and a “compiled” wheel distribution. In the case of umap, the original author uploaded only the source distribution. The safeguards PyPI has in place do not allow uploading the same distribution type again (based on strictly defined filename), but you can always upload another type – even if the package belongs now to another user.
In the context of our lock files, let’s note that in such a case, they can help you stay secure by keeping the old distribution file – but only until you regenerate the lock.
Make it gone?
So, what if we just decided that "removed” means “gone”, and started clearing distribution files when deleting a package? It’s already removed, right? Well, the world isn’t so easy. Just the concept of removing anything from a package registry has its opponents, and not without reasons. Open-source community is an interconnected network, where you never know who depends on you. NPM limited package removal possibilities after the case of the left-pad package, which was removed by the author, causing enormous downstream issues [17]. The PyPI has its case of atomicwrites [18], which triggered a long discussion [19], but up to now, there is no conclusion on the eventual policy change.
I actually don’t have a strong opinion on allowing or not removals in general. However, I believe that objects removed for security reasons, like compromised releases or malicious packages, should not be accessible through the previous delivery channels to prevent spreading the malware to unaware users. Such malicious code could still be available for research purposes, just in another place and with at least a simple protection against accidental downloading and executing.
Do ghost packages really exist?
I tried to verify if such “ghost packages” exist and someone really downloads removed distributions. PyPI publishes a lot of analytics data as BigQuery datasets, but unfortunately not enough to clearly state if (and when) the package was removed. As such, I used the “umap” example.
In the table below, you can see downloads of the last legitimate umap version after 1st January 2026. At this time this distribution was “removed” for about two months. Still, it was downloaded more than 1000 times, mainly by “uv sync” – the lock file installation command from uv.

Most downloaded umap distribution files after 2026-01-01
It’s worth mentioning that in this case, the availability of the removed distribution surely helped the developers keep their projects working. On the other hand, silent dependency on the removed package makes them more vulnerable to the name hijacking – as it was in this case. If a developer regenerated the lock file while the package was controlled by an attacker, they could lock the malicious file.
I checked a few more packages I know to be removed. In most cases, the dataset shows a few downloads from tools identified as “Browser” or mirroring utilities. I suspect the scale of ghost packages in real projects is not big, but a more in-depth investigation would be required to verify this.
Lawyers enter the room
I have a strange habit of reading legal documents, so I did look into PyPI’s Terms of Service. They have two interesting points related to removing content. First, you obviously grant the Python Software Foundation some rights to distribute what you upload. However [15]:
Because you retain ownership of and responsibility for Your Content, we need you to grant us — and other PyPI Users — certain legal permissions, provide in subections 4 — 6 below. These license grants apply to Your Content. If you upload Content that already comes with a license granting PSF the permissions we need to run our Service, no additional license is required. You understand that you will not receive any payment for any of the rights granted in subsections 4 — 6 below. The licenses you grant to us will end when you remove Your Content from our servers.
And further, we can also find a fragment about deleting an account [16]:
We will retain and use your information as necessary to comply with our legal obligations, resolve disputes, and enforce our agreements, but barring legal requirements, we will delete your full profile and the Content of your Project(s) and/or Organizations immediately upon cancellation or termination (though some information may remain in encrypted backups). This information can not be recovered once your Account is canceled. Activity logs reflecting user actions taken on Projects and Organizations are retained as long as reasonably required.
I’m not a lawyer, and I see plenty of edge cases. As a PyPI user, I feel the theory and practice could be a bit misaligned. I notified PSF about my doubts, and they are analyzing the situation.
How should I live?
To sum up, I presented that removed packages could still be reached from the PyPI infrastructure, and using uv.lock files can be used to hide malicious packages surviving removal from the index. Permanent existence of removed releases may have some advantages, but it’s not the most intuitive policy. I did not check how other package indexes behave.
Regardless of the ecosystem, if you leverage lock files in your projects, be sure that your dependency security scanning covers them and not just the initial dependency declaration. If you wonder what to do when installing an untrusted project from GitHub or similar services, the answer is simple: don’t.
References
[1] https://blog.pypi.org/posts/2024-08-16-safety-and-security-engineer-year-in-review/#project-lifecycle-status-quarantine
[2] https://packaging.python.org/en/latest/specifications/project-status-markers/
[3] https://jfrog.com/blog/revival-hijack-pypi-hijack-technique-exploited-22k-packages-at-risk/
[4] https://blog.pypi.org/posts/2025-01-30-archival/
[5] https://peps.python.org/pep-0792/
[6] https://packaging.python.org/en/latest/specifications/project-status-markers/
[7] https://peps.python.org/pep-0592/
[8] https://pip-tools.readthedocs.io/en/stable/
[9] https://snyk.io/blog/why-npm-lockfiles-can-be-a-security-blindspot-for-injecting-malicious-modules/
[10] https://peps.python.org/pep-0751/
[11] https://github.com/kam193/lock-the-ghost
[12] https://www.reversinglabs.com/blog/fake-recruiter-campaign-crypto-devs
[13] https://en.wikipedia.org/wiki/XZ_Utils_backdoor
[14] https://github.com/kam193/package-campaigns/issues/5#issuecomment-3756880711
[15] https://policies.python.org/pypi.org/Terms-of-Service/#3-ownership-of-content-right-to-post-and-license-grants
[16] https://policies.python.org/pypi.org/Terms-of-Service/#2-upon-cancellation
[17] https://en.wikipedia.org/wiki/Npm_left-pad_incident
[18] https://github.com/untitaker/python-atomicwrites/issues/61
[19] https://discuss.python.org/t/stop-allowing-deleting-things-from-pypi/17227