Dependencies are necessary. Dependency hell is not. Here's how to stay sane.

Pin or Range?

Pinning: Lock to exact versions

requests==2.31.0

Ranging: Allow compatible versions

requests>=2.28,<3.0

My approach: Range in pyproject.toml, pin in lockfile.

# pyproject.toml - flexible
[project]
dependencies = [
    "requests>=2.28",
    "pydantic>=2.0,<3.0",
]
# requirements.lock - exact
requests==2.31.0
pydantic==2.6.1

Development uses the lockfile for reproducibility. The ranges allow flexibility when others depend on your package.

The Lockfile

Generate a lockfile for reproducible builds:

pip freeze > requirements.lock

Or better, use pip-tools:

pip-compile pyproject.toml -o requirements.lock

Commit the lockfile. CI and production install from it:

pip install -r requirements.lock

Update Strategy

Don't: Update everything at once

pip install --upgrade *  # Recipe for breakage

Do: Update incrementally

# Update one package
pip install --upgrade requests
pip freeze > requirements.lock
# Test
# Commit

Schedule updates:

  • Security patches: immediately
  • Minor versions: weekly
  • Major versions: monthly, with testing

Dependabot / Renovate

Automate dependency updates:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: pip
    directory: "/"
    schedule:
      interval: weekly
    open-pull-requests-limit: 5

PRs arrive automatically. Review, test, merge.

Avoiding Dependency Hell

Minimize dependencies. Every package is a risk. Do you need requests or will urllib work?

Check maintenance status. Last commit 3 years ago? Find an alternative.

Audit transitive dependencies. Your 5 direct dependencies might pull in 50 more:

pip show requests | grep Requires
pipdeptree

Watch for conflicts. If package A needs foo>=2.0 and package B needs foo<2.0, you're stuck.

Version Constraints

package>=1.0      # Minimum version
package>=1.0,<2.0 # Range (recommended for libs)
package~=1.4      # Compatible release (>=1.4, <2.0)
package==1.4.*    # Prefix match
package==1.4.2    # Exact (for lockfiles)

For libraries you publish: use ranges. For apps you deploy: use lockfiles.

Security Scanning

Check for known vulnerabilities:

pip-audit
safety check

Add to CI:

- name: Security scan
  run: pip-audit --require-hashes -r requirements.lock

When Dependencies Break

  1. Check the changelog. What changed?
  2. Pin to last working version. Buy time.
  3. Report the issue. Help the maintainer.
  4. Fork if necessary. Last resort.
# Temporary pin while upstream is broken
dependencies = [
    "broken-package==1.2.3",  # TODO: unpin after issue #123 fixed
]

My Workflow

  1. Add dependency to pyproject.toml with range
  2. Regenerate lockfile with pip-compile
  3. Test that everything works
  4. Commit both files together
  5. Review Dependabot PRs weekly

Tools I Use

  • pip-tools: Compile pyproject.toml to lockfile
  • pip-audit: Security scanning
  • pipdeptree: Visualize dependency tree
  • Dependabot: Automated updates

The Goal

Dependencies should be:

  • Explicit: Listed in one place
  • Reproducible: Same versions everywhere
  • Updatable: Easy to bump versions
  • Secure: No known vulnerabilities

Get this right and dependencies become boring. Boring is good.

React to this post: