Change a Field Type in PyQGIS
Numbers stored as text are one of the most common data problems in GIS. A CSV import guessed wrong, a spreadsheet had one cell with "n/a" in a numeric column, a database exported everything as strings. The symptoms are confusing: sorting puts "100" before "20", graduated styles refuse the field, sums concatenate instead of adding. Dates stored as text cause the same trouble with every time-based tool. Changing the field's type fixes it — but most formats cannot change a column's type in place, and blind conversion silently turns bad values into NULL.
This recipe belongs to Attribute Tables & Field Management. It explains why types are hard to change in place, converts with refactor fields into a new layer, converts in place by adding a new field and copying, finds values that fail to convert before they are lost, and handles dates and number formats.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series.
- Write access to the layer, or a place to write a converted copy.
- Knowledge of how values are formatted — decimal comma or point, date order — before converting.
Understand why in-place type changes are rare
GeoPackage, shapefile and most file formats store a column's type in the table definition; changing it means rewriting the table. QGIS's providers therefore generally do not offer "change type" as an edit. Two approaches work everywhere: write a new layer with the converted schema, or add a new field of the right type to the existing layer, copy converted values into it, and remove the old one.
from qgis.core import QgsProject, QgsVectorDataProvider
layer = QgsProject.instance().mapLayersByName("measurements")[0]
caps = layer.dataProvider().capabilities()
for name, flag in (("add fields", QgsVectorDataProvider.AddAttributes),
("delete fields", QgsVectorDataProvider.DeleteAttributes),
("rename fields", QgsVectorDataProvider.RenameAttributes)):
print(f"{name:<14}", bool(caps & flag))
for f in layer.fields():
print(f"{f.name():<16} {f.typeName():<10} {f.type()}")
Breakdown: The capability flags tell you which route is possible on this provider: adding, deleting and renaming fields together allow the in-place route. Printing each field's provider type name and QGIS type shows which columns are text that should not be — a String column called depth_m is the usual suspect. PostgreSQL can change types with ALTER TABLE … ALTER COLUMN … TYPE … USING, which is the best route when the data lives there; see executing SQL on PostGIS.
Find values that will not convert
Before converting, find the values that cannot be parsed. Conversion turns them into NULL without complaint, and the evidence is gone.
import re
def parses_as_number(text, decimal=","):
if text is None:
return True
t = str(text).strip().replace(" ", "")
if decimal == ",":
t = t.replace(".", "").replace(",", ".")
return re.fullmatch(r"[-+]?\d+(\.\d+)?([eE][-+]?\d+)?", t) is not None or t == ""
bad = {}
for f in layer.getFeatures():
v = f["depth_m"]
if not parses_as_number(v):
bad[f.id()] = v
print(len(bad), "values will not convert:", list(bad.values())[:10])
Breakdown: The test mirrors the conversion you intend to do: here, European formatting where a dot groups thousands and a comma marks decimals. Typical failures are placeholders ("n/a", "-", "unknown"), units typed into the cell ("12 m"), and stray characters. Listing a sample lets you decide per pattern: placeholders become NULL deliberately, units can be stripped, genuine typos go back to whoever owns the data. Empty strings parse as "no value" and become NULL.
Convert into a new layer with refactor fields
When writing a converted copy is acceptable, native:refactorfields changes the type and cleans values in one step.
import processing
from qgis.PyQt.QtCore import QVariant
clean_expr = """to_real(
replace(replace(regexp_replace(trim("depth_m"), '[^0-9,.-]', ''), '.', ''), ',', '.')
)"""
mapping = []
for f in layer.fields():
if f.name() == "depth_m":
mapping.append({"name": "depth_m", "type": QVariant.Double, "length": 10,
"precision": 2, "expression": clean_expr})
else:
mapping.append({"name": f.name(), "type": f.type(), "length": f.length(),
"precision": f.precision(), "expression": f'"{f.name()}"'})
converted = processing.run("native:refactorfields", {
"INPUT": layer, "FIELDS_MAPPING": mapping, "OUTPUT": "memory:measurements_typed"})["OUTPUT"]
nulls = sum(1 for f in converted.getFeatures() if f["depth_m"] is None
or (hasattr(f["depth_m"], "isNull") and f["depth_m"].isNull()))
print(nulls, "NULL depths after conversion")
Breakdown: The expression strips anything that is not a digit, comma, dot or minus sign, removes thousands separators, turns the decimal comma into a point and converts with to_real. Every other field is copied unchanged, so the output differs from the input only in the one column. The NULL count afterwards should equal the original NULLs plus the values flagged in the check — if it is higher, the cleaning expression and the check disagree. Renaming and reordering fields explains the mapping format in more detail.
Convert in place with a new field
To keep the same table — its id, relations, styles and forms — add a typed field, fill it from the old one, then swap names.
from qgis.core import QgsField, edit
def to_float(v):
if v is None or (hasattr(v, "isNull") and v.isNull()):
return None
t = re.sub(r"[^0-9,.-]", "", str(v)).replace(".", "").replace(",", ".")
try:
return float(t) if t else None
except ValueError:
return None
with edit(layer):
layer.addAttribute(QgsField("depth_m_num", QVariant.Double, len=10, prec=2))
layer.updateFields()
new_idx = layer.fields().indexOf("depth_m_num")
old_idx = layer.fields().indexOf("depth_m")
for f in layer.getFeatures():
layer.changeAttributeValue(f.id(), new_idx, to_float(f[old_idx]))
Breakdown: Adding the field inside the edit session and calling updateFields makes it available immediately. The Python converter applies the same rules as the expression, so the earlier check describes its failures exactly. At this stage both columns exist side by side, which is the moment to verify before removing anything. Doing the copy through the edit buffer means a failure rolls everything back.
Verify, then swap
Compare old and new values for a sample and summarise before dropping the text column. Once it is gone, so is the evidence of what the raw values were.
vals = [f["depth_m_num"] for f in layer.getFeatures() if f["depth_m_num"] is not None]
print(f"min {min(vals):.2f} max {max(vals):.2f} n={len(vals)}")
for f in list(layer.getFeatures())[:5]:
print(repr(f["depth_m"]), "→", f["depth_m_num"])
with edit(layer):
layer.deleteAttribute(layer.fields().indexOf("depth_m"))
layer.updateFields()
layer.renameAttribute(layer.fields().indexOf("depth_m_num"), "depth_m")
print(layer.fields().field("depth_m").typeName())
Breakdown: Plausible minimum and maximum values catch systematic errors such as a decimal separator handled the wrong way, which turns 12,5 into 125. The side-by-side sample shows the conversion on real values. Renaming the new column to the original name keeps expressions, labels and styles that refer to depth_m working — they now see a number. Keep the list of failed values in a log; it is often the most useful output for whoever maintains the data.
Convert text to dates
Dates stored as text need the same care, with the added problem of ordering: 03/04/2026 is March or April depending on country.
date_expr = """CASE
WHEN regexp_match("visited", '^\\\\d{4}-\\\\d{2}-\\\\d{2}$') THEN to_date("visited")
WHEN regexp_match("visited", '^\\\\d{2}\\\\.\\\\d{2}\\\\.\\\\d{4}$') THEN to_date("visited", 'dd.MM.yyyy')
WHEN regexp_match("visited", '^\\\\d{2}/\\\\d{2}/\\\\d{4}$') THEN to_date("visited", 'dd/MM/yyyy')
ELSE NULL END"""
dated = processing.run("native:fieldcalculator", {
"INPUT": layer, "FIELD_NAME": "visited_date", "FIELD_TYPE": 3,
"FORMULA": date_expr, "OUTPUT": "memory:"})["OUTPUT"]
Breakdown: Matching each known pattern explicitly and parsing it with its own format string avoids ambiguity; anything that matches no pattern becomes NULL and should appear in a failure list first, as with numbers. The format string follows Qt's date notation — dd.MM.yyyy for 03.04.2026 as 3 April. Decide the day/month order from the data's origin, not by guessing per value. Field type 3 in the field calculator is date.
QGIS version compatibility
native:refactorfields, addAttribute, deleteAttribute and renameAttribute work on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. Expression functions to_real, to_date with a format argument and regexp_match are available on all. On QGIS 4, field types are QMetaType.Type values and provider capabilities use Qgis.VectorProviderCapability.
Troubleshooting
- Converted values are 100 times too large. The decimal separator was misread; check the cleaning order.
- Many values became NULL. A placeholder or unit was not handled; run the check first.
- Styles broke after the swap. The new field was not renamed to the old name.
deleteAttributefails. The provider cannot delete fields; use the refactor route.
Conclusion
Find unconvertible values before converting, convert into a new layer with an expression per field or in place with a new field, keep both columns until you have verified samples and statistics, then delete the text column and give the new one the original name; handle dates with explicit formats per pattern.
Frequently Asked Questions
Can I change a field type in a shapefile? Not in place. Write a converted copy, preferably to GeoPackage.
Why does to_int truncate decimals?
It converts to integer by truncation; use round() first if you need rounding.
How do I convert numbers to text with leading zeros?lpad(to_string("code"), 5, '0') turns 42 into "00042".
Why are the backslashes doubled in the date expression?
The regular expression \d must reach QGIS as \\d inside an expression string literal, and Python needs each of those backslashes escaped again in a normal string. Using a raw Python string (r"""...""") halves the count and is easier to read.
Can I convert a whole table's text columns at once? Yes — loop over the fields, test each text column with the parse check, and add a converted mapping entry only for columns where every non-empty value parses. Review the list before running it.
Will converting break joins? Joins on a field require matching types on both sides; convert both, or join on an expression.