I work on Java, Python and Node.js projects, and they need different runtime versions. Some use an old LTS release and some use the current one. I use one version manager per language: SDKMAN for Java, pyenv for Python and nvm for Node.js. This note shows how to install each one on a Mac, how to switch versions, and how to pin a version to a project.
Why not use the system runtimes
macOS includes some runtimes, but they are for the operating system. They change with the macOS version, they are often old, and you need sudo to modify them. There is also only one version of each, so two projects that need different versions cannot both run.
A version manager installs any number of versions in your home folder and switches between them without sudo.
Java with SDKMAN
SDKMAN installs and switches JDK versions. It also manages Gradle, Maven, Kotlin and the Spring Boot CLI. It works on macOS and Linux.
Install
curl -s "https://get.sdkman.io" | bash
Restart the terminal or run source ~/.zshrc, then check the installation:
sdk version
Install a JDK
List the available Java distributions:
sdk list java
The list includes Temurin (Eclipse Adoptium), Corretto (Amazon), Zulu (Azul), GraalVM and others. I use Temurin for most projects. I choose another distribution only when a project needs a specific JVM. Install a version by its identifier:
# Install Java 21 (LTS), Temurin distribution
sdk install java 21.0.3-tem
# Install Java 17 (LTS), Temurin distribution
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
Add a .sdkmanrc file to the project root:
# .sdkmanrc
java=17.0.11-tem
Run sdk env in that folder to activate the version. If you set sdkman_auto_env=true in ~/.sdkman/etc/config, SDKMAN switches the version every time you cd into the folder.
Python with pyenv
pyenv is the most common version manager for Python. It puts small wrapper scripts in front of the python and python3 commands, and the wrappers run the active version.
Install
On a Mac, install it with Homebrew:
brew install pyenv
Add these lines to ~/.zshrc:
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
Restart the terminal, then check the installation:
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
The command creates a .python-version file in the folder. pyenv reads this file every time you run Python inside the folder.
# .python-version
3.11.9
Node.js with nvm
nvm works like pyenv, for Node.js. It keeps several Node versions installed and switches between them for a project or for a shell session.
Install
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
The installer adds its setup lines to ~/.zshrc. Restart the terminal, then check the installation:
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
nvm use without arguments reads this file. nvm does not switch on cd by itself. To switch automatically, add this hook 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
Using the three tools together
With the three tools installed, every project works the same way:
- Clone the repository.
cdinto it.- If the project has
.sdkmanrc,.python-versionor.nvmrc, the shell switches to those versions. - If it has none of them, set the versions once and commit the files.
Commit the version files. They tell everyone who clones the repository which runtime to use, and most CI systems read them. GitHub Actions can read the version from .nvmrc and .python-version directly.
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.nvmrc