unslothai/unsloth · error · ValueError
save_steps / save_total_limit must be whole numbers, got {se
Error message
save_steps / save_total_limit must be whole numbers, got {self.save_steps!r} / {self.save_total_limit!r} What it means
Raised when save_steps or save_total_limit cannot be converted with int(): the validator wraps the conversion in try/except (TypeError, ValueError) and re-raises with both offending values echoed. These checkpointing knobs arrive through the Studio config path where blanks/strings are common, so '' or 'abc' or None-adjacent junk lands here rather than crashing int() with a bare traceback.
Source
Thrown at studio/backend/core/training/diffusion_train_common.py:1057
# Refuse fp16 for a bf16-only DiT family up front, before evicting resident models.
if self.mixed_precision == "fp16" and resolved_family in _FORCE_BF16_FAMILIES:
raise ValueError(
f"'{resolved_family}' LoRA training requires bf16: fp16 overflows its fp32 "
f"RoPE / embedder internals. Set mixed precision to bf16."
)
if str(self.lr_scheduler) not in _LR_SCHEDULERS:
raise ValueError(
f"lr_scheduler must be one of {', '.join(sorted(_LR_SCHEDULERS))}; "
f"got {self.lr_scheduler!r}"
)
if not 1 <= int(self.cache_variants) <= 16:
raise ValueError("cache_variants must be between 1 and 16")
# Checkpointing knobs. Rejected here, before the route evicts resident GPU models, rather than deep in the loop.
try:
save_steps = int(self.save_steps or 0)
save_total_limit = int(self.save_total_limit or 0)
except (TypeError, ValueError) as exc:
raise ValueError(
f"save_steps / save_total_limit must be whole numbers, got "
f"{self.save_steps!r} / {self.save_total_limit!r}"
) from exc
if save_steps < 0:
raise ValueError("save_steps must be >= 0 (0 disables periodic checkpoints)")
if save_total_limit < 0:
raise ValueError("save_total_limit must be >= 0 (0 keeps every checkpoint)")
# A blank resume path (the Studio default when the field is present but unset) means "fresh run", not the outputs root.
resume_from_checkpoint = (
str(self.resume_from_checkpoint).strip()
if self.resume_from_checkpoint is not None
else ""
) or None
# The H3 loop does not checkpoint: it neither writes a resume bundle nor restores one.
# Accepting these two silently was the dangerous part -- a caller handing over a resume
# bundle got a FRESH optimization that then overwrote the outputs it was meant to
# continue, and one asking for periodic saves got none, both discovered only after an
# expensive run. Refuse in validation, where it costs nothing, until the loop supports it.View on GitHub (pinned to 203007d190)
Solutions
- Send integers (or omitted/None) for save_steps and save_total_limit — e.g. save_steps=500, save_total_limit=3.
- If building the payload from a form, convert blank strings to None before submission so the `or 0` default applies.
- The error echoes both values; fix whichever one shows as non-numeric in the message.
Example fix
# before config = TrainConfig(save_steps='500 steps') # after config = TrainConfig(save_steps=500)
Defensive patterns
Strategy: validation
Validate before calling
def check_checkpoint_knobs(save_steps, save_total_limit) -> tuple[int, int]:
def as_int(v, default=0):
if v in (None, ""):
return default
return int(v) # raises for junk like '500 steps' or lists
return as_int(save_steps), as_int(save_total_limit) Type guard
def are_valid_checkpoint_knobs(save_steps, save_total_limit) -> bool:
for v in (save_steps, save_total_limit):
if v in (None, ""):
continue
try:
int(v)
except (TypeError, ValueError):
return False
return True Try / catch
try:
session.submit_training(config)
except ValueError as e:
if "save_steps / save_total_limit" in str(e):
config.save_steps, config.save_total_limit = None, None # fall back to defaults
session.submit_training(config)
else:
raise Prevention
- Send ints (or None) for both checkpoint knobs; convert blank form strings to None at your boundary.
- Remember one bad value of the pair fails both — the message echoes both reprs, so inspect it.
- Keep numbers unquoted and unit-free in YAML/JSON ('500', not '500 steps').
When it happens
Trigger: Passing save_steps='' (blank string from an unset form field), 'every 500', 500.5, [500], or None handling that bypasses the `or 0` default (only falsy values get defaulted — a truthy non-numeric string reaches int()). One bad value of the pair fails both, since they are validated together.
Common situations: Studio UI submits the field present-but-blank; hand-written YAML quoting numbers as prose ('500 steps'); values forwarded from another tool's JSON where the field is an object or list.
Related errors
- save_steps must be >= 0 (0 disables periodic checkpoints)
- save_total_limit must be >= 0 (0 keeps every checkpoint)
- save_steps is not supported for {resolved_family}: its train
- ema_decay must be a number, got {self.ema_decay!r}
- gradient_accumulation_steps must be >= 1
AI-assisted analysis of unslothai/unsloth@203007d190 (2026-08-15).
Data as JSON: /api/errors/f4a568f7da91d8b1.
Report an issue: GitHub.