diff --git a/ntab/fn.py b/ntab/fn.py
index 23a3ecd..04282ed 100644
--- a/ntab/fn.py
+++ b/ntab/fn.py
@@ -1,8 +1,32 @@
+import numpy as np
+
 from   .lib import container
 from   .tab import Table
 
 #-------------------------------------------------------------------------------
 
+def concat(*tabs):
+    """
+    Concatenates `tabs`.
+
+    All must have the same names and dtypes, up to order.
+    """
+    dtypes = { n: a.dtype for n, a in tabs[0].arrs.items() }
+
+    # First check dtypes.
+    for tab in tabs[1 :]:
+        tab_dtypes = { n: a.dtype for n, a in tab.arrs.items() }
+        if tab_dtypes != dtypes:
+            raise TypeError(f"dtypes do not match: {tab_dtypes}")
+
+    # Collect and concatenate arrays.
+    return Table(
+        (n, np.concatenate([ t.arrs[n] for t in tabs ]))
+        for n in dtypes
+    )
+        
+
+
 def select_arrs(tab, names):
     """
     Returns a table with arrays selected by container `names`.
diff --git a/ntab/tab.py b/ntab/tab.py
index 1b504b7..c89d23e 100644
--- a/ntab/tab.py
+++ b/ntab/tab.py
@@ -55,7 +55,7 @@ def _ensure_array(obj, length):
 
 #-------------------------------------------------------------------------------
 
-class ArraysObjectProxy:
+class ArraysObjectProxy(object):
     """
     Proxy for arrays by name as attributes.
     """
@@ -145,7 +145,7 @@ class ArraysProxy(collections.MutableMapping):
 
 
 
-class Row:
+class Row(object):
 
     def __init__(self, arrs, idx):
         self.__arrs = arrs
@@ -245,7 +245,7 @@ class RowsProxy(collections.Sequence):
 
 #-------------------------------------------------------------------------------
 
-class Table:
+class Table(object):
     """
     Data table consisting of named parallel `ndarray`s.
 

add a concatenate function
diff --git a/ntab/test/test_fn.py b/ntab/test/test_fn.py
index 7752478..537cf80 100644
--- a/ntab/test/test_fn.py
+++ b/ntab/test/test_fn.py
@@ -1,8 +1,39 @@
+import pytest
+
 from   ntab import Table, fn
 from   ntab.lib.container import ALL, all_but
 
 #-------------------------------------------------------------------------------
 
+def test_concat():
+    tab0 = Table(x=[3,4,5], y=[4,5,6], z=[5,6,7], w=[6,7,8])
+    tab1 = Table(y=[2,1], z=[3,2], x=[0,1], w=[1,0])
+
+    res = fn.concat(tab0, tab1, tab0)
+    assert res.names == ["x", "y", "z", "w"]
+    assert res.num_rows == 8
+    assert list(res.a.x) == [3, 4, 5, 0, 1, 3, 4, 5]
+    assert list(res.a.y) == [4, 5, 6, 2, 1, 4, 5, 6]
+    assert list(res.a.z) == [5, 6, 7, 3, 2, 5, 6, 7]
+    assert list(res.a.w) == [6, 7, 8, 1, 0, 6, 7, 8]
+
+
+def test_concat_err():
+    tab0 = Table(x=[3,4,5], y=[4,5,6], z=[5,6,7], w=[6,7,8])
+    
+    tab1 = Table(y=[2,1], z=[3,2], x=[0,1], w=["foo", "bar"])
+    with pytest.raises(TypeError):
+        fn.concat(tab0, tab1)
+
+    tab2 = Table(y=[2,1], x=[0,1], w=[1,0])
+    with pytest.raises(TypeError):
+        fn.concat(tab0, tab2)
+
+    tab3 = Table(x=[3,4,5], q=[7,8,9], y=[4,5,6], z=[5,6,7], w=[6,7,8])
+    with pytest.raises(TypeError):
+        fn.concat(tab0, tab3)
+    
+
 def test_select_arrs():
     tab = Table(x=[3,4,5], y=[4,5,6], z=[5,6,7], w=[6,7,8])
     assert tab.names == ["x", "y", "z", "w"]

pip install -e .
parse_log_pytest
requirements.txt
pytest
apt-get update apt-get install -y gcc
3.9
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
exceptiongroup==1.2.2
iniconfig==2.1.0
-e git+https://github.com/alexhsamuel/ntab.git@7af4eb3ce145f975fb6b03595caeeffa9afe9a6d#egg=ntab
numpy==2.0.2
packaging==24.2
pluggy==1.5.0
pytest==8.3.5
tomli==2.2.1

name: ntab
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - exceptiongroup==1.2.2
      - iniconfig==2.1.0
      - numpy==2.0.2
      - packaging==24.2
      - pluggy==1.5.0
      - pytest==8.3.5
      - tomli==2.2.1
prefix: /opt/conda/envs/ntab

ntab/test/test_fn.py::test_concat ntab/test/test_fn.py::test_concat_err
ntab/test/test_fn.py::test_select_arrs ntab/test/test_fn.py::test_get_const_empty ntab/test/test_fn.py::test_get_const_one ntab/test/test_fn.py::test_get_const
ntab/fn.py ntab/tab.py
ntab/fn.py ntab/tab.py