How Novafreeze Actually Works When You Try to Use It

I spent about three weeks debugging why my builds were failing when I tried to integrate Novafreeze into an existing pipeline. The documentation is fine, but it doesn't tell you what happens when you have nested dependencies or when the freeze point hits during a hot reload cycle. I'm going to explain how it works, what went wrong for me, and how to avoid the same issues. Novafreeze is a build-time optimization tool that freezes dependency resolution at compile time. Instead of resolving packages on every run, it locks them to a snapshot. This should save time. It usually does, but only if your dependency tree isn't changing between builds. When it works, you can cut startup time from about 45 seconds down to roughly 8 seconds on a typical project. That's meaningful if you're running iterative dev cycles. The mechanism is straightforward. It scans your requirements, writes a frozen lockfile, and subsequent builds reuse that exact state. No network calls. No version drift. The problem is that the freeze point needs to be chosen carefully. If you freeze too early in the dependency chain, you'll miss updates to transitive dependencies. If you freeze too late, you get the same non-determinism you're trying to avoid.

Setting It Up Without Breaking Your Project

Here's the configuration I ended up using after a few failed attempts: The --depth=transitive flag is critical. Without it, Novafreeze only freezes direct dependencies and lets transitive ones float. That defeats the purpose. The --strategy=semver-pin option pins to exact versions within the allowed semver range instead of locking to the latest compatible version. This prevents surprise updates from breaking your build. I also discovered that you need to exclude certain packages from freezing. Any package that handles its own dynamic imports or loads plugins at runtime will fail if frozen. For my project, that meant excluding plugin_loader, dynamic_config, and a few internal utilities. You can specify exclusions with --exclude=package_name. If you don't do this, the frozen build will raise import errors at runtime that are extremely difficult to debug because the traceback points to the freeze file instead of the actual failing module.

Common Pitfalls I Ran Into

The first issue I hit was a false sense of security. Novafreeze reports success when it writes the freeze file, but it doesn't validate that all runtime paths actually work. My build completed cleanly, but when I ran the integrated tests, three modules failed because they needed dynamic imports that the freeze had locked out. The solution was to add a validation step after applying the freeze: The --import-mode=importlib flag forces pytest to use importlib instead of the default import mechanism, which bypasses the frozen module cache. This revealed the failing tests that the clean build hadn't caught. Another problem was cache invalidation. When I updated a single dependency, Novafreeze didn't invalidate the freeze file properly. The build reused the old snapshot and silently missed the update. I had to add an explicit cache bust: novafreeze freeze --invalidate-cache whenever I changed the requirements file. Without this, you could be running code that doesn't match your declared dependencies, which causes subtle bugs that are nearly impossible to reproduce.

When Novafreeze Completely Fails

This tool has significant limitations that the documentation glosses over. First, it doesn't work well with projects that use conditional imports based on runtime environment variables. If your code does if os.getenv('ENV') == 'prod': import prod_module, the freeze file will include both modules regardless of the actual environment. This doubles your frozen snapshot size and can cause import errors in production when the optional module isn't available. Second, Novafreeze struggles with monorepos that have overlapping dependency specifications across packages. Each package might declare a different version of the same dependency, and the freeze process doesn't resolve conflicts intelligently. It picks the first version it encounters and sticks with it, which can cause runtime errors in packages that expected the newer version. For monorepos, I recommend using a dedicated lockfile manager like pip-tools or poetry instead of Novafreeze. Third, there's a bottleneck with large dependency trees. If your project has more than 500 packages, the freeze process can take upwards of 15 minutes on a typical machine. The apply step is faster, but still slower than an unfrozen build because it needs to validate each frozen module against the lockfile. For large projects, this overhead might not be worth the startup time savings.

My Workaround for Conditional Imports

After spending two days debugging why my production builds were failing, I found a workaround. Instead of trying to freeze the entire dependency tree, I created a separate freeze file for runtime dependencies only and left development tools unfrozen. This reduced the freeze file size by about 60% and eliminated the conditional import errors: The --scope=runtime flag tells Novafreeze to only freeze packages imported at runtime, not test dependencies or build tools. This is a pragmatic solution that acknowledges the tool's limitations without trying to work around them. You still get the startup time savings for the packages that matter most, and you avoid the false sense of security that comes with freezing everything. If Novafreeze doesn't fit your use case, there are other options. For simple projects, pip freeze with a requirements.txt file is often sufficient. It doesn't provide the same level of control, but it's built into Python and requires no additional tooling. For projects that need more sophisticated dependency management, poetry or pip-tools provide better lockfile management without the runtime validation issues I encountered with Novafreeze.

I also found that combining Novafreeze with docker layer caching can mitigate some of the performance issues. By freezing the dependency installation step into a separate docker layer, subsequent builds reuse that layer even when the application code changes. This usually cuts the total build time from about 10 minutes down to roughly 2 minutes for incremental builds. The trade-off is that you need to manage the docker layer invalidation manually, which adds complexity to your CI/CD pipeline.