windmill-labs/windmill · error

Cannot parse '{s}' as numeric: {e}

Error message

Cannot parse '{s}' as numeric: {e}

What it means

convert_val converts a JSON string argument into a tokio-postgres ToSql parameter. When the declared arg type is numeric/decimal, it parses the string into a rust_decimal Decimal and wraps any parse failure as 'Cannot parse ... as numeric'. The comment in the source notes the value deliberately goes through client-side parsing so Postgres doesn't fail on a `<numeric> = text` comparison.

Source

Thrown at backend/windmill-worker/src/pg_executor.rs:1644

                Error::ExecutionErr(format!("Cannot parse '{s}' as timestamptz: {e}"))
            })?;
            Ok((Box::new(datetime), Type::TIMESTAMPTZ))
        }
        Value::String(s) if arg_t == "bytea" => {
            let bytes = engine::general_purpose::STANDARD
                .decode(s)
                .unwrap_or(vec![]);
            Ok((Box::new(bytes), Type::BYTEA))
        }
        // Parse Strings into the matching native Rust type for the remaining
        // recognised arg_ts that didn't have a dedicated arm. Without these,
        // a string value lands in the generic Value::String fallback below
        // (Box<String> + TEXT) and the server-side comparison
        // `<numeric|real|...> = text` fails since PG has no implicit cast.
        Value::String(s) if arg_t == "numeric" || arg_t == "decimal" => s
            .parse::<Decimal>()
            .map(|d| (Box::new(d) as Box<dyn ToSql + Sync + Send>, Type::NUMERIC))
            .map_err(|e| anyhow::anyhow!("Cannot parse '{s}' as numeric: {e}").into()),
        Value::String(s) if arg_t == "real" || arg_t == "float4" => s
            .parse::<f32>()
            .map(|n| (Box::new(n) as Box<dyn ToSql + Sync + Send>, Type::FLOAT4))
            .map_err(|e| anyhow::anyhow!("Cannot parse '{s}' as real: {e}").into()),
        Value::String(s)
            if arg_t == "double" || arg_t == "double precision" || arg_t == "float8" =>
        {
            s.parse::<f64>()
                .map(|n| (Box::new(n) as Box<dyn ToSql + Sync + Send>, Type::FLOAT8))
                .map_err(|e| anyhow::anyhow!("Cannot parse '{s}' as double: {e}").into())
        }
        Value::String(s) if arg_t == "oid" => s
            .parse::<u32>()
            .map(|n| (Box::new(n) as Box<dyn ToSql + Sync + Send>, Type::OID))
            .map_err(|e| anyhow::anyhow!("Cannot parse '{s}' as oid: {e}").into()),
        Value::String(s) if arg_t == "bool" || arg_t == "boolean" => {
            // Accept the same literals Postgres' boolin() does.
            let b = match s.to_ascii_lowercase().as_str() {

View on GitHub (pinned to e474e8803c)

Solutions

  1. Normalize the string to plain decimal notation (dot separator, no symbols or grouping) before invoking the step
  2. Strip currency symbols and thousand separators in the step script before passing the value
  3. Guard against NaN/Infinity upstream (they are not valid decimals) and substitute null or a sentinel
  4. Pass a JSON number when precision allows; reserve string only for high-precision values

Example fix

// before
args: { price: "€1.234,56" }
// after
args: { price: "1234.56" }  // normalized decimal string
Defensive patterns

Strategy: validation

Validate before calling

function assertDecimal(s) {
  if (typeof s !== "string") return;
  const normalized = s.trim().replace(/^[^\d-+]+/, "").replace(/,/g, m => m === "," ? "." : m);
  if (!/^[+-]?\d+(\.\d+)?$/.test(normalized) || /nan|infinity/i.test(s)) {
    throw new Error(`value ${JSON.stringify(s)} is not a plain decimal`);
  }
}

Type guard

const isPlainDecimal = (v) => typeof v === "string" && /^[+-]?\d+(\.\d+)?$/.test(v.trim());

Try / catch

try {
  await runPgStep(args);
} catch (e) {
  if (String(e.message).includes("as numeric")) {
    throw new Error(`Argument must be a plain decimal string, got: ${e.message}`);
  }
  throw e;
}

Prevention

When it happens

Trigger: A step argument typed numeric/decimal receives a Value::String that rust_decimal cannot parse: 'NaN', 'Infinity', '1,234.56' with thousand separator, an exponent form Decimal rejects, or non-numeric text.

Common situations: European decimal comma format ('12,50'); currency strings ('$19.99'); NaN/Infinity produced by JS math then stringified; numbers pasted from spreadsheets with grouping separators or currency symbols.

Related errors


AI-assisted analysis of windmill-labs/windmill@e474e8803c (2026-09-03). Data as JSON: /api/errors/31a56bbc1a0282ef. Report an issue: GitHub.