Working across Java, Python, and Node.js means working across different runtime requirements: old LTS versions, current releases, sometimes both on the same machine. Without a version manager for each, switching projects means reinstalling runtimes by hand or staying pinned to whatever happens to be installed.
This post covers SDKMAN (Java), pyenv (Python), and nvm (Node.js) on Mac and how they fit into a consistent version management workflow.
The problem with system runtimes
macOS ships with some runtimes pre-installed, but those aren’t meant for development. They’re tied to the OS version, they’re often outdated, and touching them requires sudo. They also provide exactly one version, so two projects that disagree on the runtime cannot coexist.
Every language needs a version manager: installed once, it installs and switches between any runtime version.
Java — SDKMAN
SDKMAN manages JDK versions, plus Gradle, Maven, Kotlin, and the Spring Boot CLI. It’s the standard tool for this job on Mac and Linux.
Install
curl -s "https://get.sdkman.io" | bash
Restart the terminal (or source ~/.zshrc), then verify:
sdk version
Install a JDK
List available Java distributions:
sdk list java
The list includes Temurin (Eclipse Adoptium), Corretto (Amazon), Zulu (Azul), GraalVM, and others. For most projects I use Temurin, the community reference build. I only pick another distribution when a project requires a specific JVM. Install a specific version:
# Install Java 21 (LTS) — Temurin distribution
sdk install java 21.0.3-tem
# Install Java 17 (LTS) — also Temurin
sdk install java 17.0.11-tem
Switch versions
# Use 21 in the current shell session only
sdk use java 21.0.3-tem
# Set 21 as the global default
sdk default java 21.0.3-tem
# Check what's active
java -version
Per-project pinning
Drop a .sdkmanrc file in the project root:
# .sdkmanrc
java=17.0.11-tem
Then run sdk env inside that directory to activate it. With sdkman_auto_env=true in ~/.sdkman/etc/config, SDKMAN switches automatically on cd.
Python — pyenv
pyenv is the standard version manager for Python. It intercepts the python and python3 commands and routes them to whichever version is currently active.
Install
The cleanest way on Mac is through Homebrew:
brew install pyenv
Then add this to ~/.zshrc:
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
Restart the terminal, then verify:
pyenv --version
Install a Python version
# See all available versions
pyenv install --list
# Install specific versions
pyenv install 3.12.3
pyenv install 3.11.9
Switch versions
# Set global default
pyenv global 3.12.3
# Set for the current shell only
pyenv shell 3.11.9
# Verify
python --version
Per-project pinning
# Inside the project directory
pyenv local 3.11.9
This creates a .python-version file in the directory, and every cd into that folder picks it up automatically.
# .python-version
3.11.9
Node.js — nvm
nvm does for Node.js exactly what pyenv does for Python. It manages multiple Node installations and lets us switch between them per-project or per-session.
Install
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
The installer adds the initialization to ~/.zshrc automatically. Restart the terminal, then verify:
nvm --version
Install a Node version
# List available versions (LTS only)
nvm ls-remote --lts
# Install specific versions
nvm install 20 # installs latest 20.x (LTS Iron)
nvm install 18 # installs latest 18.x (LTS Hydrogen)
nvm install --lts # installs the latest active LTS
Switch versions
# Use a version in the current session
nvm use 20
# Set the default for all new shells
nvm alias default 20
# Verify
node --version
npm --version
Per-project pinning
Create a .nvmrc file in the project root:
# .nvmrc
20
Running nvm use with no arguments inside the project reads the file. To make it automatic on cd, add this to ~/.zshrc:
autoload -U add-zsh-hook
load-nvmrc() {
local nvmrc_path
nvmrc_path="$(nvm_find_nvmrc)"
if [ -n "$nvmrc_path" ]; then
local nvmrc_node_version
nvmrc_node_version=$(nvm version "$(cat "${nvmrc_path}")")
if [ "$nvmrc_node_version" = "N/A" ]; then
nvm install
elif [ "$nvmrc_node_version" != "$(nvm version)" ]; then
nvm use
fi
elif [ -n "$(PWD=$OLDPWD nvm_find_nvmrc)" ] && [ "$(nvm version)" != "$(nvm version default)" ]; then
echo "Reverting to nvm default version"
nvm use default
fi
}
add-zsh-hook chpwd load-nvmrc
load-nvmrc
How the three tools fit together
Once everything is set up, the workflow for any project looks like this:
- Clone the repo
cdinto it- If it has
.sdkmanrc,.python-version, and.nvmrc, the runtimes switch automatically - If it doesn’t, set the version once and commit those files
The version files are small, should be committed, and tell anyone cloning the repo exactly what runtime to use. They’re also read by most CI systems. GitHub Actions, in particular, has native support for .nvmrc and .python-version.
Quick reference
sdk list javapyenv versionsnvm lssdk install java <ver>pyenv install <ver>nvm install <ver>sdk default java <ver>pyenv global <ver>nvm alias default <ver>sdk use java <ver>pyenv shell <ver>nvm use <ver>.sdkmanrc.python-version.nvmrcOnce this is in place, cd into a project loads the right runtime, and version switching requires no further attention.
The next post in the series covers the shell itself: prompt, aliases, and terminal tooling.