- Gymnasium provides environment APIs only—you need Stable Baselines3 or RLlib to actually train agents with PPO, SAC, or DQN.
- Stable Baselines3 offers 10-line training scripts, readable source code, and hyperparameters tuned for single-machine learning—ideal for 90% of beginner projects.
- RLlib scales to distributed clusters but adds 30-120 minutes of setup overhead, complex Ray configs, and harder debugging for minimal speed gains on small projects.
- Start with SB3 for faster iteration on reward shaping and environment design; migrate to RLlib only when training actually takes days instead of hours.
Most Beginners Pick the Wrong RL Framework
You don’t need RLlib. I’ll say it again: if you’re starting reinforcement learning from scratch, RLlib is overkill that will slow you down for at least your first three projects.
The RL framework landscape looks intimidating because everyone conflates three separate concerns: environment APIs (Gymnasium), algorithm libraries (Stable Baselines3), and distributed training platforms (RLlib). Beginners see “production-grade” and “scalable” in RLlib’s docs and assume that’s what serious ML engineers use. But here’s what actually happens: you spend two days debugging Ray cluster configs, another day figuring out why your custom callback isn’t firing, and you still haven’t trained a single agent.
I’m going to walk through the actual API complexity, setup time, and cognitive overhead of each framework using a concrete example—training PPO on a custom environment. By the end, you’ll know exactly which tool matches your current skill level and project scope.

Gymnasium: Just the Environment, Nothing More
Gymnasium (the maintained fork of OpenAI Gym) isn’t an RL framework. It’s an environment API standard. That’s it.
When you pip install gymnasium, you get:
– A consistent interface for environments (reset(), step(), render())
– A few dozen built-in environments (CartPole, MountainCar, etc.)
– Wrappers for preprocessing (frame stacking, normalization)
– Vectorized environment support for parallel sampling
What you don’t get: algorithms. Gymnasium has zero training code. You’re expected to bring your own PPO, DQN, or whatever.
Here’s the minimal code to run a random agent:
import gymnasium as gym
env = gym.make("CartPole-v1", render_mode="human")
observation, info = env.reset(seed=42)
for _ in range(1000):
action = env.action_space.sample() # random action
observation, reward, terminated, truncated, info = env.step(action)
if terminated or truncated:
observation, info = env.reset()
env.close()
This runs instantly. No config files, no cluster setup, no abstractions. But notice what’s missing: there’s no learning happening. To actually train an agent, you’d need to implement the full RL algorithm yourself—policy networks, value functions, advantage estimation, the works.
That’s where the confusion starts. Gymnasium is a prerequisite for the other two frameworks, not an alternative to them.
Stable Baselines3: Algorithms Out of the Box
SB3 is what most people actually want when they say “I want to learn RL.” It’s a collection of well-tested, PyTorch-based RL algorithms that work with Gymnasium environments.
The value prop: you can train state-of-the-art agents in 10 lines of code.
import gymnasium as gym
from stable_baselines3 import PPO
env = gym.make("CartPole-v1")
model = PPO("MlpPolicy", env, verbose=1)
model.learn(total_timesteps=10000)
obs, info = env.reset()
for _ in range(1000):
action, _states = model.predict(obs, deterministic=True)
obs, reward, terminated, truncated, info = env.step(action)
if terminated or truncated:
obs, info = env.reset()
This actually works. Run it right now—it’ll train a CartPole agent to 500 reward in under a minute on a laptop.
SB3 includes:
– Algorithms: PPO, A2C, SAC, TD3, DQN, DDPG, HER
– Policy networks: MLP, CNN, custom architectures
– Utilities: callbacks for logging, model checkpointing, evaluation
– Tensorboard integration: built-in logging of episode rewards, value loss, policy entropy
The learning curve is gentle because SB3 makes opinionated choices. Default hyperparameters are sensible. The API is consistent across algorithms—swap PPO for SAC and your code still runs. Error messages actually tell you what’s wrong (“observation space doesn’t match policy input shape”).
But here’s the catch: SB3 is single-machine only. If you want to train across multiple GPUs or nodes, you’re out of luck. The maintainers explicitly state they prioritize simplicity and correctness over distributed scaling.
RLlib: The Distributed Beast
RLlib is Ray’s RL library, designed for production workloads that need to scale horizontally. It can train agents across dozens of workers in parallel, handle massive batch sizes, and tune hyperparameters with population-based training.
Here’s the equivalent CartPole training:
import ray
from ray import tune
from ray.rllib.algorithms.ppo import PPOConfig
ray.init()
config = (
PPOConfig()
.environment("CartPole-v1")
.rollouts(num_rollout_workers=2)
.framework("torch")
.training(train_batch_size=4000)
)
algo = config.build()
for i in range(10):
result = algo.train()
print(f"Iteration {i}: reward={result['episode_reward_mean']}")
algo.stop()
ray.shutdown()
This is more verbose. You need to understand Ray’s actor model (what’s a rollout worker?), config builders (why not just kwargs?), and the training loop structure (why manual iteration instead of .learn()?).
And that’s before you hit the real pain points:
– Ray cluster config: If you want multi-node training, you’re editing YAML files and debugging SSH keys
– Custom environments: RLlib expects environments registered in a specific way, not just any Gymnasium env
– Debugging: When something breaks, you get Ray’s distributed stack traces—good luck finding the actual error in 50 lines of actor serialization failures
– Documentation: RLlib’s docs assume you already know RL. They explain how to configure every knob, not why you’d want to.
The reward update rule in PPO is:
where is the probability ratio and is the estimated advantage. In SB3, this is implemented once, clearly, in ppo.py. In RLlib, it’s split across multiple files with distributed sampling logic interleaved, making it harder to verify correctness or customize the loss.
RLlib shines when you need:
– Parallel sampling: 100+ environment instances collecting experience simultaneously
– Multi-GPU training: Training a policy network that doesn’t fit on one GPU
– Hyperparameter tuning at scale: Running 50 PPO configs in parallel with Ray Tune
– Production deployment: Serving trained policies with Ray Serve
But if you’re training a single agent on MuJoCo or Atari? RLlib’s overhead isn’t buying you anything.
The Setup Time Reality Check
Let’s be concrete about what “getting started” actually means.
Gymnasium (5 minutes):
pip install gymnasium[classic-control]
python run_random_agent.py # it just works
Stable Baselines3 (10 minutes):
pip install stable-baselines3[extra]
python train_ppo.py # works, tensorboard logs appear
RLlib (30-120 minutes):
pip install ray[rllib] # 500MB+ download
# Try to run example
# Hit "ray.init() failed: dashboard port already in use"
# Google error, find GitHub issue from 2023
# Export RAY_DASHBOARD_PORT=8266
# Try again
# Hit "torch not found" even though it's installed
# Realize RLlib needs torch installed *before* ray[rllib]
# Uninstall, reinstall in correct order
# Finally runs, but now custom env doesn't register
# Read docs about tune.register_env()
# One hour later: training works
I’m not exaggerating. RLlib’s dependency management is fragile. Ray has strong opinions about Python versions (3.9-3.11 only as of Ray 2.9). If you’re on Apple Silicon, add another 20 minutes for architecture-specific issues.

When the “Wrong” Choice Is Right
Here’s the counterintuitive part: even if you eventually need RLlib’s features, starting with SB3 is faster.
Scenario: You’re building a custom trading environment for portfolio optimization. You need PPO with a custom reward function and some domain-specific observation preprocessing.
SB3 approach (Day 1-3):
– Day 1: Build Gymnasium environment, test with random agent
– Day 2: Train SB3 PPO, iterate on reward function until agent learns something useful
– Day 3: Add custom features to policy network, tune hyperparameters
– Result: Working baseline agent, deep understanding of your environment’s dynamics
RLlib approach (Day 1-5):
– Day 1: Setup Ray, debug environment registration
– Day 2: Figure out why custom observation space breaks RLlib’s preprocessing
– Day 3: Finally get training to run, realize default config is wrong for your env
– Day 4: Read RLlib docs to understand config options, retrain
– Day 5: Agent still doesn’t learn—is it the reward function or a config bug?
– Result: Uncertain whether problems are your environment or the framework
The SB3 path gives you faster feedback loops. When something breaks, you know it’s your code, not Ray’s distributed runtime.
And if you later need distributed training? The environment and reward shaping work transfers directly. You’ll just swap SB3’s PPO() for RLlib’s PPOConfig(). But you’ll do it with a working environment and a trained baseline to compare against.
The API Complexity Tax
Let’s compare how each framework handles a common task: logging custom metrics during training.
SB3: Subclass a callback.
from stable_baselines3.common.callbacks import BaseCallback
class RewardComponentLogger(BaseCallback):
def _on_step(self):
if len(self.model.ep_info_buffer) > 0:
self.logger.record("custom/sharpe_ratio",
self.compute_sharpe())
return True
model = PPO("MlpPolicy", env, verbose=1)
model.learn(total_timesteps=10000, callback=RewardComponentLogger())
This works on first try. The callback API has five methods (_on_step, _on_rollout_end, etc.), all optional. PyCharm autocomplete shows you what’s available.
RLlib: Override a method in a custom callback class, register it in the config, and hope the execution order matches your assumptions.
from ray.rllib.algorithms.callbacks import DefaultCallbacks
class CustomMetricsCallback(DefaultCallbacks):
def on_episode_step(self, *, worker, base_env, policies,
episode, env_index, **kwargs):
# Wait, do I use on_episode_step or on_episode_end?
# And where's the policy object to compute Sharpe ratio?
pass
config = (
PPOConfig()
.environment("TradingEnv-v0")
.callbacks(CustomMetricsCallback)
)
The RLlib callback API has 15+ methods. Some fire on workers, some on the main process. The documentation explains when each fires, but not where to access the data you need. You’ll spend 20 minutes grepping the source code to find where episode rewards are stored.
This pattern repeats everywhere: RLlib exposes more knobs, but each knob requires understanding Ray’s execution model.
Hyperparameter Sensitivity Matters More Than You Think
One underrated factor: SB3’s default hyperparameters are battle-tested on standard benchmarks. RLlib’s defaults are tuned for distributed performance, not single-machine sample efficiency.
Example: PPO’s n_steps (rollout length) and batch_size.
SB3 defaults for PPO("MlpPolicy", env):
– n_steps=2048
– batch_size=64
– n_epochs=10
This gives stable gradient updates on most continuous control tasks. Plug it into HalfCheetah-v4, and you’ll see monotonic reward improvement.
RLlib’s default PPOConfig() uses:
– train_batch_size=4000
– sgd_minibatch_size=128
– num_sgd_iter=30
These are chosen for throughput on clusters with 16+ workers. On a single machine with 2 workers, you’ll get noisy gradient estimates and unstable learning. You need to manually tune down to train_batch_size=2048, sgd_minibatch_size=64—basically reconstructing SB3’s defaults.
If you’re debugging why your agent isn’t learning, you want to eliminate “wrong hyperparameters” as a variable. SB3 does that out of the box.
The Custom Environment Pain Point
Every real RL project eventually needs a custom environment. This is where framework abstractions either help or hurt.
I covered this in detail in Custom Gymnasium Environment: Portfolio Project Guide, but here’s the short version:
Gymnasium: Define __init__, reset(), step(). That’s it.
import gymnasium as gym
import numpy as np
class TradingEnv(gym.Env):
def __init__(self, prices):
self.prices = prices
self.action_space = gym.spaces.Discrete(3) # hold, buy, sell
self.observation_space = gym.spaces.Box(
low=-np.inf, high=np.inf, shape=(10,), dtype=np.float32
)
def reset(self, seed=None, options=None):
super().reset(seed=seed)
self.current_step = 0
return self._get_obs(), {}
def step(self, action):
# implement trading logic
reward = self._compute_reward(action)
self.current_step += 1
terminated = self.current_step >= len(self.prices)
return self._get_obs(), reward, terminated, False, {}
SB3: Just use the Gymnasium env directly. No registration needed.
env = TradingEnv(prices=load_stock_data())
model = PPO("MlpPolicy", env)
model.learn(total_timesteps=10000)
RLlib: Register the environment with a string ID, pass a config dict, hope it serializes correctly for distributed workers.
from ray.tune.registry import register_env
def env_creator(config):
return TradingEnv(prices=config["prices"])
register_env("trading-v0", env_creator)
config = (
PPOConfig()
.environment("trading-v0", env_config={"prices": prices})
)
This looks reasonable until you try to debug. If TradingEnv crashes during step(), SB3 gives you a normal Python stack trace. RLlib gives you a Ray actor error that hides the actual exception behind serialization noise. You’ll need to add print statements (which may or may not appear, depending on which worker crashes) or attach a debugger to a remote Ray process.
The Learning Curve Gradient
Here’s my mental model for framework difficulty:
- Gymnasium: 1 hour to internals mastery (read the 200-line
Envbase class) - Stable Baselines3: 4-8 hours to comfortable usage, 20 hours to understand algorithm implementations
- RLlib: 20 hours to get anything working, 100+ hours to understand distributed execution and confidently debug issues
The SB3 codebase is shockingly readable. The PPO implementation in stable_baselines3/ppo/ppo.py is ~400 lines including comments. You can read it in 30 minutes and understand exactly how advantages are computed, how the policy network is updated, what clipping does. When something breaks, you can step through with a debugger.
RLlib’s PPO is split across ppo.py, ppo_torch_policy.py, rollout_worker.py, and half a dozen mixins. The value function loss computation is:
where is the generalized advantage estimation target. In SB3, this lives in one function. In RLlib, target computation happens in compute_gae_for_sample_batch(), loss in vf_loss_fn(), and gradient clipping in a separate mixin. Tracing the full path requires understanding Ray’s remote execution—you can’t just use pdb.
If you want to customize the loss function (common in research), SB3 makes it easy. RLlib makes it possible, but you’ll spend a week reading source code first.
Performance Is Not the Bottleneck (Yet)
The most common beginner mistake: optimizing for training speed before having a working agent.
RLlib’s marketing emphasizes “5x faster training” and “linear scaling to 100+ GPUs.” That’s true—for massive environments like Dota 2 or multi-agent simulations. But for most projects, sample efficiency (how many environment steps to convergence) matters more than wall-clock time.
Consider training PPO on Ant-v4 (MuJoCo robotics task):
– SB3 on 1 CPU: 2M timesteps to 3000+ reward, ~20 minutes
– RLlib on 1 CPU + 4 workers: 2M timesteps to 3000+ reward, ~15 minutes
– RLlib on 8 GPUs: Same 2M timesteps, ~5 minutes
For a hobbyist or researcher iterating on reward functions, the 5-minute setup time of SB3 vs. the 60-minute setup + config tuning of RLlib wipes out any training speed gains. You’ll spend more time reading RLlib docs than you save in compute time.
When does training speed dominate? When you’re running hundreds of experiments (hyperparameter sweeps), or when a single training run takes days (e.g., Atari games with 100M+ frames). At that point, RLlib’s investment pays off. But you’ll know when you hit that scale—your laptop fan will be screaming and training will take overnight.
What About Other Frameworks?
Two honorable mentions:
CleanRL: Single-file implementations of RL algorithms, designed for readability and educational use. Each algorithm is one standalone .py file, no abstractions. Great for understanding how PPO really works, but you’re expected to copy-paste and modify the file for your project. No library API, no pip install.
Tianshou: Chinese-origin RL library with a similar philosophy to SB3, but with better support for offline RL and multi-agent scenarios. Less documentation in English, smaller community. If SB3 doesn’t have the algorithm you need (e.g., QMIX for cooperative MARL), check Tianshou.
Neither changes the core decision: Stable Baselines3 is the right default for 90% of RL beginners.
FAQ
Q: Can I use Stable Baselines3 with custom PyTorch models?
Yes. SB3 lets you pass a custom policy network via the policy_kwargs argument. You can define your own nn.Module, specify activation functions, add recurrent layers, or plug in a pretrained CNN encoder. The API is straightforward: just match the expected input/output dimensions for your action/observation spaces.
Q: Does RLlib support all the same algorithms as Stable Baselines3?
Mostly, but with different priorities. RLlib has better support for multi-agent RL (QMIX, MADDPG) and distributed methods like IMPALA. SB3 has cleaner implementations of single-agent algorithms (PPO, SAC, TD3) and better offline RL support (DQN with replay buffer utilities). If you need a specific algorithm, check both libraries’ docs—sometimes one has it and the other doesn’t.
Q: What if I need to scale up later—do I have to rewrite everything for RLlib?
Not entirely. Your Gymnasium environment, reward shaping logic, and observation preprocessing transfer directly. The algorithm-specific code (calling PPO.learn() vs. PPOConfig.build()) does need rewriting, but that’s usually <100 lines. The hard part—designing your environment’s state/action spaces and reward function—is framework-agnostic. I’ve migrated SB3 projects to RLlib in a day once the environment was solid.
The Verdict for Your Next Project
Use Gymnasium if: You’re implementing an RL algorithm from scratch (coursework, research, deep understanding). You’ll write your own PPO, and Gymnasium provides the environment interface.
Use Stable Baselines3 if: You want to train agents on standard or custom environments without becoming a distributed systems expert. This covers 90% of hobbyist projects, most research, and early-stage prototypes. Grab a copy of Hands-On Reinforcement Learning with Python if you want worked examples beyond the docs.
Use RLlib if: You’re training at scale (multi-node clusters), need production deployment with Ray Serve, or have specific multi-agent requirements. This is for teams with ML infrastructure, not solo learners.
Start with SB3. If you hit performance bottlenecks that matter (training takes days, not hours), then evaluate RLlib. But chances are, you’ll finish three projects with SB3 before you need anything more powerful.
The biggest risk in RL isn’t picking the “wrong” framework. It’s spending three weeks fighting tool complexity instead of debugging why your agent learned to exploit a loophole in your reward function. Pick the tool that gets out of your way fastest, and you’ll actually finish what you started.
Did you find this helpful?
Your support keeps this blog running and ad-free content coming.
☕ Buy me a coffeeMost Popular Posts
- Custom Metaclass in Python: 43% Faster Validation (12,796 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (947 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (763 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (657 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (552 views)