Repository: astropy/astropy (Python)

Issue:
columns parameter not working as expected in parse_single_table/parse
### Description

When using `parse_single_table` to read a VOTable in an XML file, the parameter `columns` doesn't work as expected. The parameter `columns` is internally passed to the function `parse` which describes `columns` as:

```
columns: sequence of str, optional
    List of field names to include in the output. The default is to include all fields.
```

I'm expecting `parse` to have the same issue. I haven't directly tried it, but I've verified that `columns` is being passed to `parse` appropriately.


### Expected behavior

When passing a list of columns to `columns` only the columns in the list should be present in the output. However, all the columns are present in the output, but the ones that are not in the list are shown as a masked constant (NaN) or as an empty array depending on the type declared in the file.

I'm expecting to get:
```
<VOTable length=2>
  id
int64   
-----
    1
    2
```

But I'm getting:
```
<VOTable length=2>
  id  function_id coefficients
int64    int16       object   
----- ----------- ------------
    1          --           []
    2          --           []
```

### How to Reproduce

```
from astropy.io.votable import parse_single_table
xml_file = 'sample.xml'
_usecols = ['id']
votable = parse_single_table(xml_file, columns=_usecols)
```
The contents of `sample.xml` are:
```
<?xml version="1.0" encoding="UTF-8"?>
<VOTABLE version="1.4" xmlns="http://www.ivoa.net/xml/VOTable/v1.3" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.ivoa.net/xml/VOTable/v1.3 http://www.ivoa.net/xml/VOTable/v1.3" xmlns:spec="http://www.ivoa.net/xml/SpectrumModel/v1.01">
<RESOURCE type="results" utype="u:Type">
<TIMESYS ID="time_frame" refposition="BARYCENTER" timeorigin="24.5" timescale="TCB"/><TABLE>
<FIELD datatype="long" name="id">
<DESCRIPTION>Identifier</DESCRIPTION>
</FIELD>
<FIELD datatype="short" name="function_id">
<DESCRIPTION>Identifier defining the functions</DESCRIPTION>
</FIELD>
<FIELD arraysize="*" datatype="double" name="coefficients">
<DESCRIPTION>Function coefficients</DESCRIPTION>
</FIELD>
<DATA>
<TABLEDATA>
  <TR>
    <TD>1</TD>
    <TD>2</TD>
    <TD>31125.820 3682.205 -1562.585</TD>
  </TR>
  <TR>
    <TD>2</TD>
    <TD>3</TD>
    <TD>3112.820 36822.205 13562.585</TD>
  </TR>
</TABLEDATA>
</DATA>
</TABLE>
</RESOURCE>
</VOTABLE>
```


### Versions

astropy version: '6.0.dev278+g196e3c2d2'



Discussion:
Welcome to Astropy 👋 and thank you for your first issue!

A project member will respond to you as soon as possible; in the meantime, please double-check the [guidelines for submitting issues](https://github.com/astropy/astropy/blob/main/CONTRIBUTING.md#reporting-issues) and make sure you've provided the requested details.

GitHub issues in the Astropy repository are used to track bug reports and feature requests; If your issue poses a question about how to use Astropy, please instead raise your question in the [Astropy Discourse user forum](https://community.openastronomy.org/c/astropy/8) and close this issue.

If you feel that this issue has not been responded to in a timely manner, please send a message directly to the [development mailing list](http://groups.google.com/group/astropy-dev).  If the issue is urgent or sensitive in nature (e.g., a security vulnerability) please send an e-mail directly to the private e-mail feedback@astropy.org.
I cannot tell what was the original intent in the design but this behavior seems intentional in the code. The `columns` eventually get turned into this:

https://github.com/astropy/astropy/blob/196e3c2d2e71b6fd4bf34aa37f0fc4bf706984cf/astropy/io/votable/tree.py#L2811

that is used here:

https://github.com/astropy/astropy/blob/196e3c2d2e71b6fd4bf34aa37f0fc4bf706984cf/astropy/io/votable/tree.py#L2832

that populates this:

https://github.com/astropy/astropy/blob/196e3c2d2e71b6fd4bf34aa37f0fc4bf706984cf/astropy/io/votable/tree.py#L2867-L2868

I don't see the code attempting to drop any columns.

Are you trying to load a larger-than-memory table? If not, maybe consider [astropy.table.Table unified I/O interface](https://docs.astropy.org/en/latest/io/unified.html#vo-tables) and then you can manipulate the columns as you could any [astropy.table.Table](https://docs.astropy.org/en/latest/table/index.html). If unit matters, then consider `astropy.table.QTable`.
I see. But actually, instead of dropping the columns, I was expecting the code to simply skip them. I want to avoid reading columns that I know I'm not going to use.

I may have to use this function to read files of a few GB, so I'll see whether the reading time improves when passing a list of only a few columns to `parse_single_table` and then manually selecting only the columns I need. If this weren't sufficient, I'll try the unified I/O interface you're proposing.

I still find the description in the docstring misleading, though. 
I'll ask PyVO people who use this format a lot. Perhaps they would have better insight. 

If you have a proposal on how to improve the docstring, please open a pull request. Thanks!
I wasn't aware of the `columns` feature in parsing before, but it sounds useful and I agree it's not behaving as expected (i.e., only the specified columns should exist in the output table). 

It does look like the code is skipping parsing and saving the values for those columns, which is about all it could do for read-time efficiency.  The individual values still need to be parsed in the XML sense so the reader can get to the next value, but the values are being ignored.  However, if it created the output table without the undesired columns, that could free up useful space for output tables (I don't know how much space is saved by not storing any values in those columns).
I haven't checked in terms of space, but I can confirm that the function runs faster when only passing a few columns. I have an XML file containing a table of 5,000 rows and 26 fields/columns.

```
_usecols = ['column1', 'column2', 'column3']
table = parse_single_table(xml_file, columns=_usecols)
```
took `1.35s`

whereas
```
table = parse_single_table(xml_file)
```
took `26.5s`.
> I haven't checked in terms of space, but I can confirm that the function runs faster when only passing a few columns. I have an XML file containing a table of 5,000 rows and 26 fields/columns.

Ah thanks, that's good to know!  With that time savings, it's definitely worth doing.

I am a bit concerned with the general slow performance those numbers show, so I'm thinking we should be raising the priority of issue #8191 (votable read slow).
It seems to be rather consensual that the current behaviour is unexpected. To address @pllim's interrogations, I started studying the `TableElement._parse_tabledata` method and I *think* that the current design is just as simple as possible (it's already a function with a lot of complexity) with little regards for "edge" cases such as this one. I think that's also why it's currently pure Python (which, as pointed to in #8191, is the most likely reason why parsing VOTable is slow), but I think the only thing that's missing to improve user experience and performances is man hours: I'm game to get this going if that's desired. (ping @hamogu)
I am actively maintaining and developing a Rust VOTable parser available on [crates.io](https://crates.io/crates/votable). (@neutrinoceros, you are learning Rust according to your github page  ;) ).
The source code is open (MIT license) and available on [github](https://github.com/cds-astro/cds-votable-rust).
Although it is far from been as clean, documented and tested as I would like, the code is already in production in [Aladin Lite V3](https://github.com/cds-astro/aladin-lite), in [votable-cli](https://pypi.org/project/votable-cli/) for the convertion of small-tables in various formats, and in other CDS tools.
It supports recent IVOA standards like the parsing of [MIVOT](https://www.ivoa.net/documents/MIVOT/20230620/index.html).
Maybe it could be used in a way or another to improve performances with respect to a pure python implementation.

Don't hesitate to contact me (or @ManonMarchand which is located in the office next to mine). 

@fxpineau That is awesome, thank you for sharing ! And yes, I'm learning rust, but haven't gone beyond the basics yet, so the project is probably more interesting to me than I am to it as a potential contributor (for now) ! 
Also, for transparency
- there's no precedent for adding a dependency on a crates.io rust package to astropy and I suspect it would be a stretch to propose something like this at the moment: astropy's dependencies are required to be available on `conda-forge`, and I don't think we have the man-power or expertise to maintain rust (yet).
- I'm temporary staff (my current contract with astropy ends in 2 months), so I cannot commit to medium-to-long term projects.

That said, it's still helpful to know about another implementation that's closer to the metal so we can better set our expectations of what to aim for in terms of performance !
Ok. Just in case, we have some experience of mixed Rust/Python packages with [MOCPy](https://github.com/cds-astro/mocpy) which is an astropy affiliated package available in both [pypi](https://pypi.org/project/mocpy/) and [conda](https://anaconda.org/conda-forge/mocpy), see mocpy feedstock recipe [here](https://github.com/conda-forge/mocpy-feedstock) . It is largely based on the Rust [moc crate](https://crates.io/crates/moc). Same for [cds-healpix-python](https://github.com/cds-astro/cds-healpix-python), which is not astropy affiliated, but is both in [pypi](https://pypi.org/project/cdshealpix/) and [conda](https://anaconda.org/conda-forge/cdshealpix), and is based on the [rust cdshealpix crate](https://crates.io/crates/cdshealpix).

(We also made the tool [get-license-healper](https://github.com/cds-astro/get-license-helper) on purpose to help writing mixed Python/Rust projects conda recipes.)

Also, I have a permanent position at [CDS](https://github.com/cds-astro) so those Rust codes are going to be supported on the long term.
Very interesting! Is IVOA involved in this effort? Any plan to integrate with PyVO if possible?
@pllim I am not sure of what you mean exactly.
The CDS is involved in the IVOA, in making some of its standards and implementations.
Regarding MIVOT in particular, @lmichel (the MIVOT main author) and I are interacting regularly, and he is pushing MIVOT into PyVO, see [this PR](https://github.com/astropy/pyvo/pull/497).
For the record: Two years ago, we talked with @tomdonaldson  an some other VO people about taking advantage of the MIVOT implementation to promote a new VOTable parser (e.g. RUST) in PyVO. In the end, it seemed to us preferable to remain focused on the implementation of MIVOT staying within the existing software environment.
When I see the review work required for the MIVOT PRs as they are today, I think we made the right choice. 



Patch:
diff --git a/astropy/io/votable/tree.py b/astropy/io/votable/tree.py
index edbb21eb942f..975cc0b3a6c6 100644
--- a/astropy/io/votable/tree.py
+++ b/astropy/io/votable/tree.py
@@ -2413,6 +2413,7 @@ def __init__(
         self.format = "tabledata"
 
         self._fields = HomogeneousList(Field)
+        self._all_fields = HomogeneousList(Field)
         self._params = HomogeneousList(Param)
         self._groups = HomogeneousList(Group)
         self._links = HomogeneousList(Link)
@@ -2457,11 +2458,13 @@ def ref(self, ref):
                 ref = None
             else:
                 self._fields = table.fields
+                self._all_fields = table.all_fields
                 self._params = table.params
                 self._groups = table.groups
                 self._links = table.links
         else:
             del self._fields[:]
+            del self._all_fields[:]
             del self._params[:]
             del self._groups[:]
             del self._links[:]
@@ -2525,6 +2528,22 @@ def fields(self):
         """
         return self._fields
 
+    @property
+    def all_fields(self):
+        """
+        A list of :class:`Field` objects describing the types of each
+        of the data columns. Contrary to ``fields``, this property should
+        list every field that's available on disk, including deselected columns.
+        """
+        # since we support extending self.fields directly via list.extend
+        # and list.append, self._all_fields may go out of sync.
+        # To remedy this issue, we sync back the public property upon access.
+        for field in self._fields:
+            if field not in self._all_fields:
+                self._all_fields.append(field)
+
+        return self._all_fields
+
     @property
     def params(self):
         """
@@ -2567,18 +2586,35 @@ def is_empty(self):
         """
         return self._empty
 
-    def create_arrays(self, nrows=0, config=None):
+    def create_arrays(
+        self,
+        nrows=0,
+        config=None,
+        *,
+        colnumbers=None,
+    ):
         """
         Create a new array to hold the data based on the current set
         of fields, and store them in the *array* and member variable.
         Any data in the existing array will be lost.
 
         *nrows*, if provided, is the number of rows to allocate.
+        *colnumbers*, if provided, is the list of column indices to select.
+        By default, all columns are selected.
         """
         if nrows is None:
             nrows = 0
+        if colnumbers is None:
+            colnumbers = list(range(len(self.all_fields)))
 
-        fields = self.fields
+        new_fields = HomogeneousList(
+            Field,
+            values=[f for i, f in enumerate(self.all_fields) if i in colnumbers],
+        )
+        if new_fields != self._fields:
+            self._fields = new_fields
+
+        fields = self.all_fields
 
         if len(fields) == 0:
             array = np.recarray((nrows,), dtype="O")
@@ -2588,7 +2624,9 @@ def create_arrays(self, nrows=0, config=None):
             Field.uniqify_names(fields)
 
             dtype = []
-            for x in fields:
+            for i, x in enumerate(fields):
+                if i not in colnumbers:
+                    continue
                 if x._unique_name == x.ID:
                     id = x.ID
                 else:
@@ -2619,10 +2657,14 @@ def _resize_strategy(self, size):
             return 512
         return int(np.ceil(size * RESIZE_AMOUNT))
 
-    def _add_field(self, iterator, tag, data, config, pos):
-        field = Field(self._votable, config=config, pos=pos, **data)
+    def add_field(self, field: Field) -> None:
         self.fields.append(field)
+        self.all_fields.append(field)
+
+    def _register_field(self, iterator, tag, data, config, pos) -> None:
+        field = Field(self._votable, config=config, pos=pos, **data)
         field.parse(iterator, config)
+        self._all_fields.append(field)
 
     def _add_param(self, iterator, tag, data, config, pos):
         param = Param(self._votable, config=config, pos=pos, **data)
@@ -2688,7 +2730,7 @@ def parse(self, iterator, config):
                         self.description = data or None
         else:
             tag_mapping = {
-                "FIELD": self._add_field,
+                "FIELD": self._register_field,
                 "PARAM": self._add_param,
                 "GROUP": self._add_group,
                 "LINK": self._add_link,
@@ -2699,7 +2741,7 @@ def parse(self, iterator, config):
             for start, tag, data, pos in iterator:
                 if start:
                     if tag == "DATA":
-                        if len(self.fields) == 0:
+                        if len(self.all_fields) == 0:
                             warn_or_raise(E25, E25, None, config, pos)
                         warn_unknown_attrs("DATA", data.keys(), config, pos)
                         break
@@ -2714,14 +2756,14 @@ def parse(self, iterator, config):
                         self.description = data or None
                     elif tag == "TABLE":
                         # For error checking purposes
-                        Field.uniqify_names(self.fields)
+                        Field.uniqify_names(self.all_fields)
                         # We still need to create arrays, even if the file
                         # contains no DATA section
                         self.create_arrays(nrows=0, config=config)
                         return self
 
-        self.create_arrays(nrows=self._nrows, config=config)
-        fields = self.fields
+        fields = self.all_fields
+        Field.uniqify_names(fields)
         names = [x.ID for x in fields]
         # Deal with a subset of the columns, if requested.
         if not columns:
@@ -2745,6 +2787,8 @@ def parse(self, iterator, config):
             else:
                 raise TypeError("Invalid columns list")
 
+        self.create_arrays(nrows=self._nrows, config=config, colnumbers=colnumbers)
+
         if (not skip_table) and (len(fields) > 0):
             for start, tag, data, pos in iterator:
                 if not start:
@@ -2752,21 +2796,21 @@ def parse(self, iterator, config):
 
                 if tag == "TABLEDATA":
                     warn_unknown_attrs("TABLEDATA", data.keys(), config, pos)
-                    self.array = self._parse_tabledata(
-                        iterator, colnumbers, fields, config
-                    )
+                    self.array = self._parse_tabledata(iterator, colnumbers, config)
                 elif tag == "BINARY":
                     warn_unknown_attrs("BINARY", data.keys(), config, pos)
                     self.array = self._parse_binary(
-                        1, iterator, colnumbers, fields, config, pos
+                        1, iterator, colnumbers, config, pos
                     )
                 elif tag == "BINARY2":
                     if not config["version_1_3_or_later"]:
                         warn_or_raise(W52, W52, config["version"], config, pos)
                     self.array = self._parse_binary(
-                        2, iterator, colnumbers, fields, config, pos
+                        2, iterator, colnumbers, config, pos
                     )
                 elif tag == "FITS":
+                    if config.get("columns") is not None:
+                        raise NotImplementedError
                     warn_unknown_attrs("FITS", data.keys(), config, pos, ["extnum"])
                     try:
                         extnum = int(data.get("extnum", 0))
@@ -2804,7 +2848,7 @@ def parse(self, iterator, config):
 
         return self
 
-    def _parse_tabledata(self, iterator, colnumbers, fields, config):
+    def _parse_tabledata(self, iterator, colnumbers, config):
         # Since we don't know the number of rows up front, we'll
         # reallocate the record array to make room as we go.  This
         # prevents the need to scan through the XML twice.  The
@@ -2816,14 +2860,15 @@ def _parse_tabledata(self, iterator, colnumbers, fields, config):
         array = self.array
         del self.array
 
+        fields = self.all_fields
         parsers = [field.converter.parse for field in fields]
         binparsers = [field.converter.binparse for field in fields]
 
         numrows = 0
         alloc_rows = len(array)
         colnumbers_bits = [i in colnumbers for i in range(len(fields))]
-        row_default = [x.converter.default for x in fields]
-        mask_default = [True] * len(fields)
+        row_default = [field.converter.default for field in self.fields]
+        mask_default = [True] * len(self.fields)
         array_chunk = []
         mask_chunk = []
         chunk_size = config.get("chunk_size", DEFAULT_CHUNK_SIZE)
@@ -2832,7 +2877,8 @@ def _parse_tabledata(self, iterator, colnumbers, fields, config):
                 # Now parse one row
                 row = row_default[:]
                 row_mask = mask_default[:]
-                i = 0
+                i = 0  # index of the column being read from disk
+                j = 0  # index of the column being written in array (not necessarily == i)
                 for start, tag, data, pos in iterator:
                     if start:
                         binary = data.get("encoding", None) == "base64"
@@ -2879,8 +2925,9 @@ def _parse_tabledata(self, iterator, colnumbers, fields, config):
                                     if invalid == "exception":
                                         vo_reraise(e, config, pos)
                                 else:
-                                    row[i] = value
-                                    row_mask[i] = mask_value
+                                    row[j] = value
+                                    row_mask[j] = mask_value
+                                    j += 1
                         elif tag == "TR":
                             break
                         else:
@@ -2986,9 +3033,7 @@ def careful_read(length):
 
         return careful_read
 
-    def _parse_binary(self, mode, iterator, colnumbers, fields, config, pos):
-        fields = self.fields
-
+    def _parse_binary(self, mode, iterator, colnumbers, config, pos):
         careful_read = self._get_binary_data_stream(iterator, config)
 
         # Need to have only one reference so that we can resize the
@@ -2996,6 +3041,7 @@ def _parse_binary(self, mode, iterator, colnumbers, fields, config, pos):
         array = self.array
         del self.array
 
+        fields = self.all_fields
         binparsers = [field.converter.binparse for field in fields]
 
         numrows = 0
@@ -3043,11 +3089,11 @@ def _parse_binary(self, mode, iterator, colnumbers, fields, config, pos):
             except EOFError:
                 break
 
-            row = [x.converter.default for x in fields]
-            row_mask = [False] * len(fields)
-            for i in colnumbers:
-                row[i] = row_data[i]
-                row_mask[i] = row_mask_data[i]
+            row = [x.converter.default for x in self.fields]
+            row_mask = [False] * len(self.fields)
+            for j, i in enumerate(colnumbers):
+                row[j] = row_data[i]
+                row_mask[j] = row_mask_data[i]
 
             array[numrows] = tuple(row)
             array.mask[numrows] = tuple(row_mask)
@@ -3378,7 +3424,7 @@ def from_table(cls, votable, table):
 
         for colname in table.colnames:
             column = table[colname]
-            new_table.fields.append(Field.from_table_column(votable, column))
+            new_table.add_field(Field.from_table_column(votable, column))
 
         if table.mask is None:
             new_table.array = ma.array(np.asarray(table))
@@ -3393,7 +3439,7 @@ def iter_fields_and_params(self):
         TABLE.
         """
         yield from self.params
-        yield from self.fields
+        yield from self.all_fields
         for group in self.groups:
             yield from group.iter_fields_and_params()
 
diff --git a/docs/changes/io.votable/15959.api.rst b/docs/changes/io.votable/15959.api.rst
new file mode 100644
index 000000000000..488ee77fd668
--- /dev/null
+++ b/docs/changes/io.votable/15959.api.rst
@@ -0,0 +1,5 @@
+``Table.read(..., format='votable')``, ``votable.parse`` and
+``votable.parse_single_table`` now respect the ``columns`` argument and will only output
+selected columns. Previously, unselected columns would just be masked (and unallocated).
+``astropy.io.votable.tree.TableElement.create_arrays`` also gained a ``colnumbers``
+keyword argument to allow column selection.


Test patch:
diff --git a/astropy/io/votable/tests/test_vo.py b/astropy/io/votable/tests/test_vo.py
index 2fbde75df14f..86c5e99ef6d1 100644
--- a/astropy/io/votable/tests/test_vo.py
+++ b/astropy/io/votable/tests/test_vo.py
@@ -285,7 +285,10 @@ def test_select_columns_by_index():
     columns = ["string_test", "unsignedByte", "bitarray"]
     for c in columns:
         assert not np.all(mask[c])
-    assert np.all(mask["unicode_test"])
+
+    # deselected columns shouldn't be present in the output
+    assert "unicode_test" not in array.dtype.fields
+    assert "unicode_test" not in mask.dtype.fields
 
 
 def test_select_columns_by_name():
@@ -298,7 +301,93 @@ def test_select_columns_by_name():
     assert array["string_test"][0] == "String & test"
     for c in columns:
         assert not np.all(mask[c])
-    assert np.all(mask["unicode_test"])
+
+    # deselected columns shouldn't be present in the output
+    assert "unicode_test" not in array.dtype.fields
+    assert "unicode_test" not in mask.dtype.fields
+
+
+@pytest.mark.parametrize(
+    "column_ids, use_names_over_ids, expected_names",
+    [
+        # just the first column
+        pytest.param(
+            ["string_test"],
+            False,
+            ["string_test"],
+            id="first-col-ids",
+        ),
+        pytest.param(
+            ["string_test"],
+            True,
+            ["string test"],
+            id="first-col-names",
+        ),
+        # a single column, other than the first
+        pytest.param(
+            ["unicode_test"],
+            False,
+            ["unicode_test"],
+            id="single-col-ids",
+        ),
+        pytest.param(
+            ["unicode_test"],
+            True,
+            ["unicode_test"],
+            id="single-col-names",
+        ),
+        # two non-consecutive, differently named columns
+        pytest.param(
+            ["string_test", "unicode_test"],
+            False,
+            ["string_test", "unicode_test"],
+            id="two-cols-ids",
+        ),
+        pytest.param(
+            ["string_test", "unicode_test"],
+            True,
+            ["string test", "unicode_test"],
+            id="two-cols-names",
+        ),
+        # just the first two columns (that have the same ID)
+        pytest.param(
+            ["string_test", "string_test_2"],
+            False,
+            ["string_test", "string_test_2"],
+            id="two-cols-ids-sameID",
+        ),
+        pytest.param(
+            ["string_test", "string_test_2"],
+            True,
+            ["string test", "fixed string test"],
+            id="two-cols-names-sameID",
+        ),
+        # columns should be returned in the order they are found, which
+        # in the general case isn't the order they are requested
+        pytest.param(
+            ["unicode_test", "string_test"],
+            False,
+            ["string_test", "unicode_test"],
+            id="two-cols-ids-order-mismatch",
+        ),
+        pytest.param(
+            ["unicode_test", "string_test"],
+            True,
+            ["string test", "unicode_test"],
+            id="two-cols-names-order-mismatch",
+        ),
+    ],
+)
+def test_select_columns_by_name_edge_cases(
+    column_ids, use_names_over_ids, expected_names
+):
+    # see https://github.com/astropy/astropy/issues/14943
+    filename = get_pkg_data_filename("data/regression.xml")
+    with np.errstate(over="ignore"):
+        # https://github.com/astropy/astropy/issues/13341
+        vot1 = parse_single_table(filename, columns=column_ids)
+    t1 = vot1.to_table(use_names_over_ids=use_names_over_ids)
+    assert t1.colnames == expected_names
 
 
 class TestParse:
@@ -710,6 +799,26 @@ def test_get_coosys_by_id(self):
         pass
 
 
+@pytest.mark.parametrize("format_", ["binary", "binary2"])
+def test_select_columns_binary(format_):
+    with np.errstate(over="ignore"):
+        # https://github.com/astropy/astropy/issues/13341
+        votable = parse(get_pkg_data_filename("data/regression.xml"))
+    if format_ == "binary2":
+        votable.version = "1.3"
+        votable.get_first_table()._config["version_1_3_or_later"] = True
+    votable.get_first_table().format = format_
+
+    bio = io.BytesIO()
+    # W39: Bit values can not be masked
+    with pytest.warns(W39):
+        votable.to_xml(bio)
+    bio.seek(0)
+    votable = parse(bio, columns=[0, 1, 2])
+    table = votable.get_first_table().to_table()
+    assert table.colnames == ["string_test", "string_test_2", "unicode_test"]
+
+
 def table_from_scratch():
     from astropy.io.votable.tree import Field, Resource, TableElement, VOTableFile