# Preserve decimal commas when pasting into Streamlit

When someone copies `23,4` from a spreadsheet, they usually expect to keep that value. If an input column interprets the comma as a thousands separator, the result can become `234` before the application gets a chance to validate it.

That exact symptom appears in [Streamlit issue #7866](https://github.com/streamlit/streamlit/issues/7866). A [later report](https://github.com/streamlit/streamlit/issues/9796) from the same reporter was closed as overlapping, so it should not be counted as an independent report or a separate confirmed bug. The useful lesson is to decide what the source punctuation means before converting the input.

Here is a small text-column workflow for apps that can let users paste text first and review converted values afterward. It uses Python's standard-library `Decimal` and native Streamlit widgets. The complete code below is free to use under the [MIT license](https://github.com/tevinch/data-shape-kit/blob/b3a37414efd449bcebded6bb26cb20f9c1e9c597/examples/streamlit-decimal-paste/LICENSE).

## Keep the input and the number separately

Three decisions make this workflow predictable:

- Preserve the raw string, including leading integer zeros, alongside the result.
- Choose a specific input grammar. This first example accepts comma decimals and does **not** accept thousands grouping.
- Construct `Decimal` directly from the validated text. Return a string for the JSON preview so it does not turn into a binary floating-point number.

Display formatting is a separate concern. The [NumberColumn documentation](https://docs.streamlit.io/develop/api-reference/data/st.column_config/st.column_config.numbercolumn) says its formatting does not change the value returned by `st.data_editor`. A [community discussion about localized formatting](https://discuss.streamlit.io/t/columnconfig-numbercolumn-localized/95533) also illustrates why changing a Python locale is not the same as selecting the browser's display language. Neither operation reconstructs punctuation already lost during conversion.

## A converter with a deliberately small grammar

Save this as `decimal_comma.py`. It accepts a string or `None` from the text column:

```python
import re
from decimal import Decimal

def convert(raw):
    if raw is None:
        return {"raw": raw, "status": "empty"}
    text = raw.strip(" \u00a0\u202f")
    if not text:
        return {"raw": raw, "status": "empty"}
    if len(raw) > 256 or re.fullmatch(r"[+-]?[0-9]+(?:,[0-9]+)?", text) is None:
        return {"raw": raw, "status": "invalid"}
    return {"raw": raw, "decimal": str(Decimal(text.replace(",", "."))), "status": "valid"}
```

The regular expression requires ASCII digits, an optional leading sign and, if a comma is present, digits on both sides. It rejects currency symbols, percentages, exponents, underscores, tabs, newlines and non-finite values such as `NaN`. Ordinary space, non-breaking space (`U+00A0`) and narrow non-breaking space (`U+202F`) are allowed around the whole number.

`1,234` is accepted as the decimal value `1.234`. If your source intended a grouped integer, this grammar is the wrong choice. An ambiguous string cannot tell you the user's intention on its own.

Nonempty input longer than 256 characters is rejected. The demo treats whitespace-only input as empty. The raw input remains in every report, including invalid and empty results.

## Put a text column in front of it

Save this as `app.py` beside `decimal_comma.py`:

```python
import streamlit as st
from decimal_comma import convert

st.title("Paste decimal commas as text")
st.caption("Comma decimals, without thousands grouping. Original values stay visible.")

edited = st.data_editor(
    {"Raw value": ["23,4", "0012,00"]},
    column_config={"Raw value": st.column_config.TextColumn("Raw value", max_chars=256)},
    num_rows="dynamic",
    key="raw_values",
)
if st.button("Convert"):
    st.json([convert(raw) for raw in edited["Raw value"]])
```

Use Python 3.11 or newer. Create an environment, install the version used for this example, and start the local app:

```sh
python3 -m venv .venv
. .venv/bin/activate
python -m pip install streamlit==1.63.0
python -m streamlit run app.py --server.address 127.0.0.1 --server.headless true --browser.gatherUsageStats false
```

On Windows, activate with `.venv\Scripts\activate` instead. Open the local URL printed in the terminal. Click the first cell and paste this single column, then finish editing and click **Convert**:

```text
23,4
0012,00
1.234,50
9007199254740993,50
```

The expected results are `23.4`, `12.00`, an invalid row, and `9007199254740993.50`. The third value uses dot grouping, which this minimal converter deliberately rejects. Add an empty row to see a separate `empty` result.

The JSON preview keeps decimals as strings. The numerical value of `0012,00` is represented as `12.00`; its leading integer zeros survive in the accompanying raw string. For arithmetic in your application, use `Decimal` objects and an appropriate arithmetic context. Python's [Decimal documentation](https://docs.python.org/3/library/decimal.html) explains the distinction between exact construction from text and later arithmetic, which can round according to that context.

This app sends the browser input to the Python process on your computer. It is not a browser-only converter. The example does not save the values or submit them to another service. A remote deployment would receive those values on the remote server instead.

## Check the cases that are easy to miss

Save this as `check.py` beside the converter, then run `python check.py`. No Streamlit installation is required for these checks:

```python
from decimal_comma import convert

assert convert("23,4")["decimal"] == "23.4"
assert convert("0012,00") == {"raw": "0012,00", "decimal": "12.00", "status": "valid"}
assert convert("1,234")["decimal"] == "1.234"
assert convert("1.234,50")["status"] == "invalid"
assert convert("9007199254740993,50")["decimal"] == "9007199254740993.50"
assert convert("NaN")["status"] == "invalid"
assert convert(None) == {"raw": None, "status": "empty"}
assert convert(" \u00a0")["status"] == "empty"
print("All eight decimal checks passed.")
```

These cases check parsing and the report representation. They do not replace a real paste test from the spreadsheet and browser your users have. I checked the native text-column workflow in Chrome with Streamlit 1.63.0, including multi-row paste, empty rows, invalid grouping and long values. A text column is an alternative application input path; this does not patch `NumberColumn` or recover input already changed upstream.

## When your source includes thousands grouping

For an application that needs both `1.234,50` and `1,234.50`, expose the decimal and grouping choices explicitly. I maintain a free [parser and runnable Streamlit example](https://github.com/tevinch/data-shape-kit/tree/b3a37414efd449bcebded6bb26cb20f9c1e9c597/examples/streamlit-decimal-paste) with those controls.

Copy `decimal_text.py` and `LICENSE` from that directory beside your code, then use:

```python
from decimal_text import parse_decimal_text

comma_value = parse_decimal_text("1.234,50", decimal_mark=",", group_mark=".")
dot_value = parse_decimal_text("1,234.50", decimal_mark=".", group_mark=",")
assert str(comma_value) == "1234.50"
assert str(dot_value) == "1234.50"
```

That helper validates groups of three digits, supports three explicit space characters, rejects malformed groups such as `12.34,5`, and returns `Decimal` objects. It has 12 tests covering its documented grammar. The accompanying app keeps raw values visible and clears the previous conversion report when the table or selected format changes.

Choose one source format for a conversion batch. If rows mix formats, ask the user to separate them or provide per-row format information. Guessing from punctuation can produce a plausible but incorrect number.

## Buy me a coffee, if this helped

If this saved you a little time, you're welcome to buy me a coffee. Please don't feel obliged — using the example, reporting an issue or sharing it is appreciated too.

- **USDC on Solana:** `9tY6D9mwcFaJwwzEHvw2v7nhSpdjqjNBYtuooyBN6rYy`
- **USDC on Base:** `0x568Ab98578d682FB0B0b45619BE73EbFfbf5a6eA`

Please match the asset and network exactly. Fees depend on your wallet or exchange. Thank you! — Tevinch

