Парсинг и генерация XML и CSV в Go: типичные ошибки и как их избежать
Стандартная библиотека Go умеет и XML, и CSV — но encoding/xml молча проглатывает невалидные данные, а encoding/csv ломается на кириллице с BOM. Разбираем конкретные грабли, на которые наступают чаще всего, и показываем, как их обойти без сторонних зависимостей.

Почему «просто xml.Unmarshal» — не решение
Пакеты encoding/xml и encoding/csv входят в стандартную библиотеку Go и выглядят простыми: вызвал Unmarshal или NewReader, получил структуру. На практике они молча теряют данные, путают кодировки и генерируют невалидный вывод — причём без единой ошибки в err. Ниже — конкретные ситуации, в которых это происходит, и способы защиты. Всё проверено на Go 1.22; поведение стабильно начиная с 1.19.
XML: пять граблей, на которые наступают регулярно
1. Неэкспортированные поля — тихая потеря данных
encoding/xml игнорирует неэкспортированные поля структуры без ошибки. Если поле начинается со строчной буквы, оно не попадёт ни в результат парсинга, ни в вывод Marshal. Компилятор не предупредит, тесты пройдут — но данные будут нулевыми.
// Go 1.22
package main
import (
"encoding/xml"
"fmt"
)
type Order struct {
XMLName xml.Name `xml:"order"`
ID int `xml:"id"`
total float64 `xml:"total"` // строчная «t» — поле невидимо для xml
}
func main() {
data := []byte(`42999.99`)
var o Order
_ = xml.Unmarshal(data, &o)
fmt.Println(o.ID, o.Total) // 0.00 — total не заполнилось
}
Исправление очевидно — Total float64. Но в большой кодовой базе неэкспортированное поле может появиться при рефакторинге. Привычка: после добавления XML-тегов прогоняйте минимальный раунд-трип тест — Marshal → Unmarshal → сравнение.
2. Неизвестные элементы проглатываются молча
Если в XML-документе есть элемент, которого нет в структуре, xml.Unmarshal просто его пропустит. Для кого-то это фича, для кого-то баг: вы не узнаете, что контрагент начал передавать новое поле <discount>, пока бизнес не спросит, почему скидки не применяются.
Защита — поле-ловушка с тегом ,any:
type Order struct {
XMLName xml.Name `xml:"order"`
ID int `xml:"id"`
Total float64 `xml:"total"`
Extras []xml.Token `xml:",any"` // сюда попадёт всё незнакомое
}
После парсинга проверьте len(o.Extras): если больше нуля — в документе есть элементы, которые вы не обрабатываете.
3. Кодировка отличается от UTF-8
encoding/xml из коробки работает только с UTF-8. Документ в windows-1251 (а таких в российских интеграциях — большинство) вернёт ошибку xml: encoding "windows-1251" declared but Decoder.CharsetReader is nil.
Решение — подключить golang.org/x/text и задать CharsetReader:
import (
"encoding/xml"
"golang.org/x/text/encoding/charmap"
"io"
"strings"
)
func newDecoder(input string) *xml.Decoder {
d := xml.NewDecoder(strings.NewReader(input))
d.CharsetReader = func(charset string, r io.Reader) (io.Reader, error) {
if strings.EqualFold(charset, "windows-1251") {
return charmap.Windows1251.NewDecoder().Reader(r), nil
}
return nil, fmt.Errorf("неподдерживаемая кодировка: %s", charset)
}
return d
}
4. Пространства имён: теги совпадают, но данные пустые
Если XML использует namespace (<ns:item xmlns:ns="...">), а в теге структуры пространство не указано, элемент не сматчится. Нужно прописывать полный URI:
type Item struct {
Name string `xml:"http://example.com/ns name"`
}
Пробел между URI и локальным именем — не опечатка, это синтаксис тега encoding/xml.
5. xml.Marshal генерирует документ без пролога
xml.Marshal не добавляет <?xml version="1.0"?>. Большинство парсеров на стороне потребителя это переживут, но валидатор XSD скажет, что документ некорректен. Добавляйте пролог вручную:
out, _ := xml.MarshalIndent(order, "", " ")
result := []byte(xml.Header + string(out))
Константа xml.Header уже содержит <?xml version="1.0" encoding="UTF-8"?>\n.
CSV: четыре проблемы, которые встречают на проде
1. BOM в начале файла
Excel при сохранении в UTF-8 вставляет три байта BOM (0xEF 0xBB 0xBF) перед содержимым. encoding/csv читает их как часть первого поля — и первый заголовок превращается из "id" в "\xEF\xBB\xBFid". Поиск по ключу ломается без видимых причин.
Решение — срезать BOM до передачи в ридер:
import "bytes"
func stripBOM(data []byte) []byte {
return bytes.TrimPrefix(data, []byte("\xEF\xBB\xBF"))
}
2. Разделитель — точка с запятой, а не запятая
В русской локали Excel и 1С выгружают CSV с разделителем ;. По умолчанию csv.NewReader ожидает запятую. Результат: вся строка попадает в одно поле. Исправление — одна строка:
r := csv.NewReader(file)
r.Comma = ';'
3. Разное число полей в строках
По умолчанию csv.Reader ожидает одинаковое количество полей в каждой строке и возвращает ошибку wrong number of fields, если строки различаются. Если формат действительно нестрогий (комментарии, пустые строки-разделители), отключите проверку:
r.FieldsPerRecord = -1 // разрешить произвольное число полей
Но при этом вы сами берёте на себя контроль длины среза — иначе index out of range при обращении к record[5], которого нет.
4. Генерация: кавычки и переносы строк внутри полей
csv.Writer корректно оборачивает поле в кавычки, если внутри есть запятая, кавычка или перенос строки. Но если вы собираете CSV руками через fmt.Sprintf — а это делают чаще, чем хотелось бы — ни одно из этих правил не сработает. Всегда используйте csv.Writer для генерации, не strings.Join.
Сравнение типичных ошибок
| Ошибка | Формат | Симптом | Цена в проде |
|---|---|---|---|
| Неэкспортированное поле | XML | Поле всегда нулевое | Потеря данных, баг обнаруживают поздно |
| Нет CharsetReader | XML | Ошибка при парсинге windows-1251 | Интеграция с российскими системами не работает |
| Namespace не указан в теге | XML | Элемент не матчится, поле пустое | Молчаливая потеря, как с неэкспортированным полем |
| BOM в файле | CSV | Первый заголовок содержит мусорные байты | Поиск по ключу «id» не находит ничего |
| Разделитель ≠ запятая | CSV | Вся строка — одно поле | Индексы съезжают, данные мешаются |
| Ручная сборка CSV | CSV | Поля с запятой или кавычкой ломают структуру | Импорт на стороне получателя падает |
Большинство ошибок при работе с XML и CSV в Go объединяет одно свойство: они не возвращают ошибку. Данные просто теряются или искажаются молча. Единственная надёжная защита — раунд-трип тест: закодировал, раскодировал, сравнил с исходником поле за поле.
Чек-лист перед выходом в прод
- Все поля структуры, участвующие в сериализации, — экспортированные (с большой буквы).
- У XML-декодера задан
CharsetReader, если источник может быть не UTF-8. - Пространства имён прописаны в тегах структуры полным URI.
- При генерации XML добавлен
xml.Header. - CSV-ридер настроен:
Comma,FieldsPerRecord, BOM срезан. - CSV генерируется через
csv.Writer, а не ручной конкатенацией. - Есть раунд-трип тест, покрывающий кириллицу, спецсимволы (
&,<,") и пустые значения.
Сноска: BOM (Byte Order Mark) — невидимая последовательность байтов в начале файла, указывающая кодировку и порядок байтов. В UTF-8 это три байта EF BB BF. Большинство текстовых редакторов их не показывают, но парсеры видят.
Что делать дальше
Если вы работаете с большими XML-документами (десятки мегабайт и выше), xml.Unmarshal загружает весь документ в память. Переходите на потоковый xml.Decoder с методом Token() — он читает элемент за элементом. Для CSV аналогично: csv.Reader.Read() возвращает по одной записи, а ReadAll() складывает всё в память.
Следующий шаг — написать бенчмарк на реальных данных. Замерьте аллокации через testing.B с флагом -benchmem: количество аллокаций на операцию покажет, где стандартная библиотека тратит больше, чем нужно, и стоит ли смотреть в сторону сторонних парсеров. Для XML в Go-экосистеме альтернатив немного, и стандартный пакет остаётся основным выбором — но знать его слабые места стоит до того, как они проявятся на проде.
Карточки для маркетплейсов: Таблица товаров и папка фотографий на входе — комплект слайдов и описаний на выходе, по требованиям Wildberries и Ozon.
Спросить за 5 ₽Источники: Документация пакета encoding/xml — Go standard library, Документация пакета encoding/csv — Go standard library
Частые вопросы
Почему xml.Unmarshal не возвращает ошибку, когда в документе есть неизвестные элементы?
Как обработать XML в кодировке windows-1251 без сторонних библиотек?
Что будет, если передать csv.Reader файл с разделителем «;» и не задать Comma?
А если у меня Go версии ниже 1.19 — поведение encoding/xml отличается?
Можно ли использовать encoding/xml для SOAP-запросов?
Как генерировать CSV, который Excel откроет с правильной кодировкой?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.