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

  1. Send integers (or omitted/None) for save_steps and save_total_limit — e.g. save_steps=500, save_total_limit=3.
  2. If building the payload from a form, convert blank strings to None before submission so the `or 0` default applies.
  3. 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

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


AI-assisted analysis of unslothai/unsloth@203007d190 (2026-08-15). Data as JSON: /api/errors/f4a568f7da91d8b1. Report an issue: GitHub.