Documentation
ΒΆ
Overview ΒΆ
Package csvcopy streams CSV one row at a time.
A row is read, handed over and forgotten, so memory does not depend on the size of the file. There are no dependencies outside the standard library.
Bulk-loading into PostgreSQL is what it is shaped for, and the reason the row sources expose Next/Values/Err: that method set is pgx.CopyFromSource, satisfied structurally rather than by importing pgx, so the version of pgx stays the application's choice. Nothing here requires a database, though - Reader is a plain CSV reader, and Typed decodes into your own types. Both have All for ranging.
Layers ΒΆ
Two ways to fix the shape of a file, because both turn up in practice.
Raw takes the shape from the file's own header - whatever columns arrived, in their order, all as text. This is the staging table case, where a SQL script types the data afterwards:
src, err := csvcopy.NewRaw(file)
if err != nil {
return err
}
if len(src.Columns()) == 0 {
return nil // empty file, nothing to load
}
n, err := tx.CopyFrom(ctx, pgx.Identifier{table}, src.Columns(), src)
Typed takes the shape from struct tags. Columns are matched by name, so the file may reorder them or add new ones; a column a tag asks for and the file lacks is an error. Every tagged field is a string, and convert turns the row into whatever the program actually works with - only the caller can tell an empty cell from a zero:
type row struct {
ID string `csv:"id"`
Name string `csv:"name"`
}
rows, err := csvcopy.NewTyped(file, (*row).toEntity)
if err != nil {
return err
}
source := csvcopy.NewCopy(rows, len(columns), func(dst []any, e *entity) []any {
return append(dst, e.ID, e.Name)
})
n, err := tx.CopyFrom(ctx, pgx.Identifier{table}, columns, source)
NewCopy adapts any RowSource - Typed, or one of your own over XLSX or an API - to pgx.CopyFromSource.
Without a database ΒΆ
Reader and Typed stand on their own. Ranging stops at the first bad row and Err reports it afterwards, the same shape as bufio.Scanner:
rows, err := csvcopy.NewTyped(file, (*row).toEntity)
if err != nil {
return err
}
for entity := range rows.All() {
send(entity)
}
if err := rows.Err(); err != nil {
return err
}
Reader.All yields raw records as []string, for when no decoding is wanted either.
Guarantees ΒΆ
An empty input is not an error: no columns, no rows, no error. The caller decides what that means.
A UTF-8 BOM is stripped, read with io.ReadFull so a slow reader cannot leave it in place.
The first bad row stops the stream for good. Rows are not skipped: a partly loaded table is worse than a failed load. The error is available from Err, names the line, and stays there - a source that has failed yields nothing more, so ranging All again cannot resume past the row that broke. Breaking out of a loop is not an error, and the next pull carries on from where it stopped.
Record and Line name the row that failed, so an error message can carry it.
Errors a file can cause wrap ErrParse, so an application can alias its own sentinel to it. Errors the calling code causes wrap ErrSchema instead, because no file will fix them.
Values reuses one slice between rows. That is safe under pgx.CopyFrom, which encodes a row before asking for the next; a caller driving a source by hand must not retain it.
Nothing here is safe for concurrent use, and neither is pgx.CopyFrom.
Index ΒΆ
- Variables
- func NormalizeSpace(s string) string
- type Copy
- type Option
- func WithAllowMissingColumns(allow bool) Option
- func WithComma(comma rune) Option
- func WithHeaderRow(row uint) Option
- func WithLazyQuotes(lazy bool) Option
- func WithNormalizeHeader(fn func(string) string) Option
- func WithPointerValues(pointers bool) Option
- func WithTag(tag string) Option
- func WithTrimLeadingSpace(trim bool) Option
- func WithTrimValues(trim bool) Option
- func WithVariableColumns(variable bool) Option
- type Raw
- type Reader
- type RowSource
- type Typed
- func (s *Typed[S, D]) All() iter.Seq[D]
- func (s *Typed[S, D]) Err() error
- func (s *Typed[S, D]) Header() []string
- func (s *Typed[S, D]) Line() int
- func (s *Typed[S, D]) Next() bool
- func (s *Typed[S, D]) Record() []string
- func (s *Typed[S, D]) Rows() int64
- func (s *Typed[S, D]) Unused() []string
- func (s *Typed[S, D]) Value() D
Examples ΒΆ
Constants ΒΆ
This section is empty.
Variables ΒΆ
var ErrMissingColumns = fmt.Errorf("%w: header is missing columns", ErrParse)
ErrMissingColumns reports columns that a csv tag asked for and the header does not have.
This is fatal rather than a warning: a field bound to nothing would read as the empty string on every row and reach the database as NULL, quietly wiping the column it was supposed to fill. WithAllowMissingColumns lifts the check for callers who accept that risk.
var ErrParse = errors.New("csv parse")
ErrParse is wrapped by every error that a file can cause: a malformed record, a header that does not match the struct, a failing convert. An application can therefore alias its own sentinel to it and keep its existing errors.Is checks working:
var ErrBadFile = csvcopy.ErrParse
var ErrSchema = errors.New("csv schema")
ErrSchema reports wiring that cannot work whatever the file holds: a struct that cannot be decoded into at all - not a struct, a tagged field that is not a string, a tagged field that is not exported - a nil reader or convert, or a delimiter encoding/csv will not accept.
It deliberately does not wrap ErrParse. No input file will ever fix it, so a caller that retries or quarantines files on ErrParse should not treat it as a bad file - it is a bug in the calling code.
Functions ΒΆ
func NormalizeSpace ΒΆ
NormalizeSpace is the default header normalizer: it collapses every run of whitespace into a single space and trims the ends.
Exported column names arrive wrapped across lines or padded for alignment, so "date of\n birth" and "date of birth" have to resolve to the same column.
Types ΒΆ
type Copy ΒΆ
type Copy[T any] struct { // contains filtered or unexported fields }
Copy adapts a RowSource to pgx.CopyFromSource.
Only the mapping of a value onto its columns lives here; where the values came from is none of this layer's business.
func NewCopy ΒΆ
NewCopy wraps src, laying each value out with encode.
columns is how many values encode appends; it only sizes the backing slice, so being wrong costs an allocation rather than correctness. encode must append in the same order as the column list passed to pgx.CopyFrom - the two are a pair, and Postgres cannot notice when they disagree if the types are compatible.
Panics if src or encode is nil: both are wiring, and a nil one cannot be recovered from at the point it would be noticed.
type Option ΒΆ
type Option func(*settings)
Option configures a Reader and everything built on top of it.
func WithAllowMissingColumns ΒΆ
WithAllowMissingColumns downgrades a missing tagged column from an error to silence.
Dangerous: the field will read as empty on every row and reach the database as NULL, wiping whatever that column held. Only use it where that is acceptable.
func WithComma ΒΆ
WithComma sets the field delimiter. Defaults to ';'.
A quote, a carriage return, a newline and an invalid rune cannot delimit anything encoding/csv is willing to read, so the constructor rejects them with ErrSchema rather than letting the first row fail with ErrParse.
func WithHeaderRow ΒΆ
WithHeaderRow sets which row holds the header, counting from 1. Rows before it are read and dropped, so a file may carry a title or a note above its table. Defaults to 1.
func WithLazyQuotes ΒΆ
WithLazyQuotes allows a bare quote inside an unquoted field instead of failing the record. Defaults to true.
func WithNormalizeHeader ΒΆ
WithNormalizeHeader replaces the header normalizer. Passing nil restores identity. Defaults to NormalizeSpace.
func WithPointerValues ΒΆ
WithPointerValues makes Raw hand out *string instead of string, which removes one allocation per column per row.
Putting a string into an []any boxes it, and boxing a string always allocates 16 bytes for its header - so the default costs one allocation per cell, which on a wide file dwarfs everything else. A pointer is pointer-shaped: the interface holds it directly and boxing is free. The strings live in one array allocated per file, and a value the row stopped short of is a nil interface, still NULL.
Off by default on purpose. pgx dereferences *T through its pointer encode plan and *string is the ordinary way to pass a nullable text value, so this should be transparent - but "should" is not "measured against a real database". Turn it on, load a real file, compare the result, then make it the default.
Affects Raw only. In Copy the boxing happens inside your own encode func.
func WithTrimLeadingSpace ΒΆ
WithTrimLeadingSpace drops white space at the start of a field. Defaults to true.
func WithTrimValues ΒΆ
WithTrimValues applies strings.TrimSpace to every value. Defaults to true.
func WithVariableColumns ΒΆ
WithVariableColumns accepts rows whose field count differs from the header's. Missing trailing values become NULL and extra ones are dropped.
Off by default: a row that is the wrong width usually means the delimiter or the quoting is misread, and failing beats loading shifted data.
type Raw ΒΆ
type Raw struct {
// contains filtered or unexported fields
}
Raw streams a file whose shape is only known from its own header.
Every column arrives as text, in the file's order, under the file's names - the shape of a staging table that a SQL script types afterwards. Nothing here knows or cares what the columns mean.
It satisfies pgx.CopyFromSource. That interface is not imported: Go interfaces are structural, so the method set alone is enough and the version of pgx stays the application's choice.
func (*Raw) All ΒΆ
All iterates the rows, for driving the source by hand rather than handing it to pgx.CopyFrom.
Stops at the first bad row; Err reports it afterwards. The yielded slice is reused by the next iteration.
If you want the values as text rather than as []any, range Reader.All instead - there is no reason to go through the boxing.
func (*Raw) Columns ΒΆ
Columns is the column list to pass to pgx.CopyFrom, taken from the header.
Empty when the file was empty - check it before creating a table, there is nothing to load.
func (*Raw) Err ΒΆ
Err returns the error that stopped the stream, if any. Check it before the error pgx.CopyFrom returns: this one names the line.
func (*Raw) Next ΒΆ
Next advances to the next row, stopping at the end of the file or at the first malformed record.
type Reader ΒΆ
type Reader struct {
// contains filtered or unexported fields
}
Reader is the bottom layer both sources are built on: it strips a UTF-8 BOM, configures encoding/csv, and reads the header.
It reads one record at a time and never holds more than the current one, which is what keeps the whole pipeline to a constant amount of memory no matter how large the file is. It does not close the underlying io.Reader.
func NewReader ΒΆ
NewReader reads the header and prepares the reader for row-by-row use.
An empty input is not an error: Columns returns nil and Read returns io.EOF at once. What an empty file means is the caller's decision, not this package's.
func (*Reader) All ΒΆ
All iterates the records left in the file.
Ranging cannot carry an error out, so the loop stops at the first bad record and Err reports it afterwards - the same shape as bufio.Scanner:
for record := range reader.All() {
...
}
if err := reader.Err(); err != nil {
return err
}
Breaking out of the loop leaves the reader usable, and the next pull picks up where the range left off. An error does not: the reader is done, and ranging it again yields nothing rather than resuming past the record that failed.
The yielded slice is reused by the next iteration. Copy it if it has to outlive one turn of the loop.
func (*Reader) Columns ΒΆ
Columns returns the header as it was read and normalized, or nil if the input was empty. The slice is shared, not copied - it is handed straight to pgx.CopyFrom, which does not modify it.
func (*Reader) Line ΒΆ
Line is the 1-based number of the record the reader is on, counting the header. After a failed Read it names the record that failed.
func (*Reader) Read ΒΆ
Read returns the next record, or io.EOF when there are none left.
The first bad record ends the reader: the error is returned again by every later call and stays in Err. Rows are never skipped, so a caller cannot resume past a bad record and mistake a truncated file for a whole one.
The returned slice is reused by the next call. Every error other than io.EOF wraps ErrParse and carries the line number.
func (*Reader) Record ΒΆ
Record returns the record last read, for error messages that need the offending row rather than just its number.
After a failed Read it holds that record as far as encoding/csv got with it, or nil where it could not produce one at all.
Only valid until the next Read: the backing slice is reused.
type RowSource ΒΆ
RowSource is anything that yields values one at a time: Typed, or a source of your own over XLSX, an API or a generator.
The interface is a pull, not a push, and that is the point. pgx.CopyFrom drives the loop itself - it asks for a row, encodes it into its send buffer, and only then asks for the next one. A source that pushes rows into a channel or a callback would need a goroutine between it and CopyFrom, and with it error handling across a goroutine boundary inside an open transaction.
type Typed ΒΆ
Typed streams a file whose shape is fixed by a struct.
S is the row as it appears in the file - every field a string, tagged with the column name it comes from. D is what the rest of the program works with, produced by convert. The split is deliberate: only the caller can decide whether an empty cell is a NULL or a zero, and whether a bad value fails the file or the field, so this package never converts anything itself.
Typed satisfies RowSource[D], so decoding and the layout of a COPY row can live in different layers: one hands out a RowSource, and the layer that owns the database decides which columns it lands in.
func NewTyped ΒΆ
func NewTyped[S any, D any](r io.Reader, convert func(*S) (D, error), opts ...Option) (*Typed[S, D], error)
NewTyped reads the header, binds it to S's tags and prepares a source over the rows after it.
convert is called once per row with a pointer to a struct that is reused for the whole file, so it must not keep that pointer. Returning an error from it stops the stream, and the error is reported with the line it came from.
Only the fields a column bound to are written before each call. A field no tag asked for - untagged, or tagged "-" - is never touched, so whatever convert leaves in one it will see again on the next row.
Example ΒΆ
const file = "name;id\nAlice;1\nBob;2\n"
rows, err := NewTyped(strings.NewReader(file), toEntity)
if err != nil {
panic(err)
}
source := NewCopy(rows, 2, func(dst []any, e *entity) []any {
return append(dst, e.ID, e.Name)
})
// tx.CopyFrom(ctx, pgx.Identifier{"people"}, []string{"id", "name"}, source)
for source.Next() {
values, _ := source.Values()
fmt.Println(values...)
}
if err = source.Err(); err != nil {
panic(err)
}
Output: 1 Alice 2 Bob
func (*Typed[S, D]) All ΒΆ
All iterates the decoded rows.
This is the layer that is useful with no database in sight: CSV in, your own type out, one row at a time. Stops at the first row that fails to parse or convert, and Err reports it afterwards:
for client := range rows.All() {
...
}
if err := rows.Err(); err != nil {
return err
}
Unlike the other All methods, what is yielded here is whatever convert returned, so nothing is reused behind your back.
func (*Typed[S, D]) Header ΒΆ
Header returns the header as read and normalized, or nil if the file was empty.
func (*Typed[S, D]) Next ΒΆ
Next advances to the next row, stopping at the end of the file, at the first malformed record, or at the first row convert rejects.
func (*Typed[S, D]) Record ΒΆ
Record returns the raw record behind the current row, for error messages.
func (*Typed[S, D]) Rows ΒΆ
Rows is the number of rows handed out so far. Zero after a full pass means the file held nothing, which is not in itself an error.