2026, Jan 09 07:00

Accelerate Per-Submission Python Virtual Environments for Grading with uv and Fast pip Installs

Learn how uv speeds up per-submission Python venv creation and pip installs for grading pipelines, preserving isolation with caching and faster turnaround.

When you scale a grading pipeline to hundreds of student submissions, creating a fresh Python virtual environment per folder can dominate your runtime. Isolated venvs keep runs safe and reproducible, but bootstrapping them one by one, then installing dependencies from each local requirements.txt, quickly becomes the slow path. The question is how to keep the safety guarantees of per-submission isolation without paying the full tax every time.

Baseline: per-submission venv creation in Python

The approach below builds a venv inside each submission directory and then installs dependencies. It is correct and straightforward, but slow when repeated hundreds of times.

class EnvOrchestrator:

    # Initialize submission root and venv paths
    def __init__(self, base_dir: Path):
        self._root_dir = Path(base_dir).resolve()
        self._env_dir = self._root_dir / "venv"
        self.deps_file = self._root_dir / "requirements.txt"

    # Create a virtual environment under the submission directory
    def make_env(self):
        if not self._env_dir.exists():
            outcome = subprocess.run(
                [sys.executable, "-m", "venv", str(self._env_dir)],
                capture_output=True,
                text=True
            )
            if outcome.returncode != 0:
                return False
            return True
        else:
            ...

Orchestrating this across a root directory of submissions:

def bootstrap_envs(root: Path):
    entries = [p for p in root.iterdir() if p.is_dir() and p.name.startswith("Portfolio")]

    def handle(item):
        mgr = EnvOrchestrator(item)
        mgr.make_env()
        mgr.install_deps()
        mgr.write_report()

    with ThreadPoolExecutor(max_workers=12) as pool:
        tasks = {pool.submit(handle, p): p.name for p in entries}
        for t in tqdm(as_completed(tasks), total=len(tasks), desc="Setting up venvs", unit="student"):
            who = tasks[t]
            try:
                t.result()
                print(f"Finished setup for {who}")
            except Exception as ex:
                print(f" Error processing {who}: {ex}")

Why this feels slow at scale

Two operations repeat for every submission: creating the venv and installing packages. Both are I/O-heavy, and while pip’s cache shaves off a chunk after the first install, the overhead remains noticeable when multiplied by 200+. The platform and filesystem matter as well. On Unix, creating a venv tends to be faster than on Windows, and placing work on tmpfs can cut I/O because it’s essentially a RAM disk and defers writes to disk under memory pressure. Switching between threads and processes rarely changes the underlying bottleneck in this flow; there’s an observation that ThreadPoolExecutor is not multiprocessing and a separate note that using threads vs processes may not buy you much here, especially since package installation already leans on caching. You can also prepare venvs ahead of time, or run each submission inside a container instead of a venv, but the core issue remains: repeated environment setup time.

A faster path: uv for envs and installs

A practical way to keep per-submission isolation and cut wall-clock time is to replace venv plus pip with uv. The gist is simple: uv is about an order of magnitude faster at creating environments and it accelerates installation too. The difference is visible in a side-by-side run of common tasks.

Using venv and pip as a baseline, one sequence took roughly 11 seconds to create an environment and 16 seconds to install NumPy on the first run, then 11 seconds for another environment and 12 seconds to reinstall NumPy with a cache hit.

Repeating the equivalent flow with uv showed about 0.3 seconds to create an environment. Installing NumPy took around 8 seconds on the first run and just 0.6 seconds on a cache hit for the next environment. On a larger project with many sizable dependencies, uv installed in about 30 seconds the first time and 0.6 seconds with caches, while pip needed 69 seconds for the first environment and 27 seconds for the second. These numbers highlight two things: environment creation speed and the effectiveness of uv’s caching.

If you switch to uv, you likely won’t feel the need to multithread, and it might not be beneficial anyway, because uv pip install is expected to use parallelism internally.

Official resources are available at https://github.com/astral-sh/uv and https://docs.astral.sh/uv/.

Applying uv to per-submission environments

The workflow maps cleanly to the per-student setup. Create an environment inside each submission folder using uv, activate it, and install dependencies with uv pip:

uv venv path/to/submission/venv
source path/to/submission/venv/Scripts/activate
uv pip install -r path/to/submission/requirements.txt
deactivate

Compared with python -m venv plus pip install, this cuts time in two places. First, uv venv completes in a fraction of a second. Second, uv pip install uses caching aggressively, so the second and subsequent environments benefit significantly, limited mostly by network time on first fetches.

An updated code path

If you’re driving everything from Python, you can swap out the environment creation step to use uv directly. Below is a minimal replacement for the creation call; dependency installation is done with the corresponding uv pip command.

class FastEnvManager:

    def __init__(self, base_dir: Path):
        self._root_dir = Path(base_dir).resolve()
        self._env_dir = self._root_dir / "venv"
        self.deps_file = self._root_dir / "requirements.txt"

    def make_env(self):
        if not self._env_dir.exists():
            result = subprocess.run(
                ["uv", "venv", str(self._env_dir)],
                capture_output=True,
                text=True
            )
            if result.returncode != 0:
                return False
            return True
        else:
            ...

    # Run the matching install step with uv pip (same requirements file)
    # Example shell usage per submission:
    #   source venv/Scripts/activate && uv pip install -r requirements.txt

In practice you’ll drop this into the same orchestration you already have for iterating submissions. With uv, the setup phase shrinks enough that parallelism may no longer be a lever you need to pull.

Why this matters for grading systems

Turnaround time is a feature. Faster environment setup means quicker feedback cycles, less time waiting on infrastructure, and a more predictable pipeline when the volume of submissions spikes. Using uv retains the safety of per-submission isolation while compressing the slowest part of the process. It also plays nicely with caching, so repeated runs across similar requirements get almost instantaneous installs after the first hit.

Takeaways

If your grader builds a fresh venv per student and installs from requirements.txt, switching to uv is a targeted optimization that preserves isolation and measurably cuts setup time. The observed improvements include roughly 0.3 seconds for environment creation and substantial reductions in install time, especially on cache hits. Platform nuances can still influence absolute numbers, and there are alternative approaches such as running each submission in a container or preparing environments ahead of time. But if the bottleneck is venv creation plus dependency installation, uv offers a direct, low-friction speedup with familiar commands and strong caching behavior.