diff --git a/doc/api.rst b/doc/api.rst
index 3184e6e..169b341 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -17,6 +17,8 @@ Plotting
 Dataset loading and generation
 ------------------------------
 
+.. autofunction:: from_contents
+
 .. autofunction:: from_memberships
 
 .. autofunction:: generate_data
diff --git a/upsetplot/__init__.py b/upsetplot/__init__.py
index 35c590d..4218a8d 100644
--- a/upsetplot/__init__.py
+++ b/upsetplot/__init__.py
@@ -1,6 +1,7 @@
 from .plotting import UpSet, plot
-from .data import generate_data, from_memberships
+from .data import generate_data, from_memberships, from_contents
 
 __version__ = '0.3-dev'
 
-__all__ = ['UpSet', 'generate_data', 'plot', 'from_memberships']
+__all__ = ['UpSet', 'generate_data', 'plot',
+           'from_memberships', 'from_contents']
diff --git a/upsetplot/data.py b/upsetplot/data.py
index f4a1825..77d95ce 100644
--- a/upsetplot/data.py
+++ b/upsetplot/data.py
@@ -97,3 +97,79 @@ def from_memberships(memberships, data=None):
                          % (len(memberships), len(data)))
     data.index = df.index
     return data
+
+
+def from_contents(contents, data=None, id_column='id'):
+    """Build data from category listings
+
+    Parameters
+    ----------
+    contents : Mapping of strings to sets
+        Keys are cateogry names, values are sets of identifiers (int or
+        string).
+    data : DataFrame, optional
+        If provided, this should be indexed by the identifiers used in
+        `contents`.
+    id_column : str, default='id'
+        The column name to use for the identifiers in the output.
+
+    Returns
+    -------
+    DataFrame
+        `data` is returned with its index indicating set membership,
+        including a column named according to id_column.
+        If data is not given, the order of rows is not assured.
+
+    Examples
+    --------
+    >>> from upsetplot import from_contents
+    >>> contents = {'cat1': ['a', 'b', 'c'],
+    ...             'cat2': ['b', 'd'],
+    ...             'cat3': ['e']}
+    >>> from_contents(contents)  # doctest: +NORMALIZE_WHITESPACE
+                      id
+    cat1  cat2  cat3
+    True  False False  a
+          True  False  b
+          False False  c
+    False True  False  d
+          False True   e
+    >>> import pandas as pd
+    >>> contents = {'cat1': [0, 1, 2],
+    ...             'cat2': [1, 3],
+    ...             'cat3': [4]}
+    >>> data = pd.DataFrame({'favourite': ['green', 'red', 'red',
+    ...                                    'yellow', 'blue']})
+    >>> from_contents(contents, data=data)  # doctest: +NORMALIZE_WHITESPACE
+                       id favourite
+    cat1  cat2  cat3
+    True  False False   0     green
+          True  False   1       red
+          False False   2       red
+    False True  False   3    yellow
+          False True    4      blue
+    """
+    cat_series = [pd.Series(True, index=list(elements), name=name)
+                  for name, elements in contents.items()]
+    if not all(s.index.is_unique for s in cat_series):
+        raise ValueError('Got duplicate ids in a category')
+    df = pd.concat(cat_series, axis=1, sort=False)
+    if id_column in df.columns:
+        raise ValueError('A category cannot be named %r' % id_column)
+    df.fillna(False, inplace=True)
+    cat_names = list(df.columns)
+
+    if data is not None:
+        if set(df.columns).intersection(data.columns):
+            raise ValueError('Data columns overlap with category names')
+        if id_column in data.columns:
+            raise ValueError('data cannot contain a column named %r' %
+                             id_column)
+        not_in_data = df.drop(index=data.index, errors='ignore')
+        if len(not_in_data):
+            raise ValueError('Found identifiers in contents that are not in '
+                             'data: %r' % not_in_data.index.values)
+        df = df.reindex(index=data.index).fillna(False)
+        df = pd.concat([data, df], axis=1, sort=False)
+    df.index.name = id_column
+    return df.reset_index().set_index(cat_names)

Feature Request: more intuitive data conversion
Currently there are three ways to use your library:

1.  generate data via the `generate_data` method
2. construct data via the `from_membership` method
3. be comfortable enough with Pandas to coerce your data to fit the expected shape (for which there is no documentation how to do)

The issue with point 2 is that it is both verbose and not how users actually keep their data and since there isn't a guide for point 3 yet many users may just ignore your library, despite you main selling point being that the alternative library lacks documentation for the input format (your documentation I would say hardly counts as a counter point.


So how might users actually store their data?

Well they probably have named sets, with elements rather than parts of venn-diagrams and the number of elements therein e.g.

```JSON
{
    "Set1": ["el1", "el2"],
    "Set2": ["el2", "el4"],
    ,...
}
```


So lets add some helper functions to would take something like above and make it compatible with your data format (this is from my personal collection of helper functions)


```python
from itertools import combinations

def subsets(setnames:list) -> list:
    ''' Gives the 2^n subsets for every set in setnames

    Args:
        setnames (list): a list of elements, where each element corresponds to
            the name of a set. e.g. ['A', 'B', ..., 'Z']
    Returns:
        subsets (list): a list of tuples, where each tuple contains the set
            names corresponding to the sets in that subset

            e.g. [(), (A, ), (B, ), ..., (A, B, ), ... (A, B, ..., Z)]
    '''
    number_of_sets = len(setnames)
    return [
        combination
        for combination_index in range(number_of_sets)
        for combination in list(combinations(setnames, combination_index))
    ] + [tuple(setnames)]

def partitions_by_length(partitions:list):
    '''Groups partitions by number of sets in each partition

    Args:
        partitions (list): a list of lists, where each sublist represents a
            partititon and each item therein corresponds to the name of a set.

            e.g. the output of subsets

    Returns:

        partitons_by_length (dict): {num: partitons} pairs where num is an int
            specifiy the number of sets in each partition, and partitions is
            a list containing all partitions with num sets.

    '''
    grouped = {}
    for partition in partitions:
        cardinality = len(partition)
        if cardinality not in grouped:
            grouped[cardinality] = []
        grouped[cardinality].append(partition)
    return grouped

def upset_elements(sets:dict, domain:set=set({})) -> dict:
    '''Gives the elements unique to each ~region~ of the venn diagram

    Args:
        sets (dict): the pair of {setname (str): elements (list)} for which to
            compute on

        domain (set): all the elements, including those which may not be seen in
            the passed sets. By default, an empty set.

    Returns:
        elements_unique_to_comparisons (dict): a dict containing the pair of
            {comparison (tuple): elements (list)}, where comparison is a tuple
            of setnames (keys from sets), e.g. ('A', 'B',) indicating the
            comparison made.

    '''
    setnames = list(sets.keys())
    # returns partitions of setnames in increasing number of sets, e.g.
    # [(), (A, ), (B, ), ..., (A, B, ), ... (A, B, ..., Z)]
    comparisons = subsets(setnames)
    comparisons.reverse()

    # group each comparison by how many sets in each comparisons e.g.
    # work our way from the inner most of the venn diagram to the outer most
    grouped_comparisons = partitions_by_length(comparisons)

    # results
    elements_unique_to_comparisons = {}

    # elements we have already seen
    seen = set({})

    # start with the intersection of all sets and work our way to the empty set
    for n_sets in range(len(setnames), -1, -1):

        partitions_with_n_sets = grouped_comparisons[n_sets]
        current_comparisons = [
            [
                # list of elements for each set in the current comparison
                sets[name] for name in comparison
            ]  for comparison in partitions_with_n_sets
            # where each partition of n sets is a comparison to make
        ]

        # the empty set
        if partitions_with_n_sets == [()]:
            # elements unique to the empty set is anything in the domain not
            # already seen
            elements_unique_to_comparisons.update({
                (): domain.difference(seen)
            })
            # nothing else to do
            continue

        intersections = [
            # elements shared across the sets in the comparison
            set.intersection(*comparison_sets)
            # for all comparisons with n sets in them
            for comparison_sets in current_comparisons
        ]

        uniques = [
            # filter out elements already seen
            intersection.difference(seen)
            for intersection in intersections
        ]

        elements_unique_to_comparisons.update(
            dict(
                list(
                    zip(
                        partitions_with_n_sets, uniques
                    ) # tuples of (sets in current partition, unique elements)
                ) # cast as a list
            ) # cast as a dict
        ) # update our results

        just_seen = set.union(*uniques)
        seen.update(just_seen)

    return elements_unique_to_comparisons
```


In this particular instance we are assuming all elements are strings. So let us add one more helper method:

```python


def to_sets(dictionary:dict, to_lower_case:bool=True):
    '''
    Arguments:
        dictionary (dict): key, value pairs where keys are setnames and values are lists of elements in the sets. 
    Returns:
        result (dict): key, value pairs where the values are now all sets.
    '''
    sets = {}
    for key, val in dictionary.items():
        sets[key] = set([v.lower() for v in val])
    return sets
```


Great now let's see how this works in practice

```python
# load sets
with open('sets.json', 'r') as f:
    data = json.load(f)

# get elements unique to each part of the venn diagram 
upset_data = upset_elements(to_sets(data)) #**

# list of tuples corresponding to what sets correspond to the venn diagram part
subsets = list(upset_data.keys())

# filter out empty subsets
min_subsets_e = list(filter(lambda t: len(upset_data[t])>0, subsets))

# convert to your format
plot_data = from_memberships(
    [list(e) for e in min_subsets], 
    data=[len(upset_data[ss]) for ss in min_subsets]
)
```

** note the `upset_data` is a more useful format than the input format as it lets users also keep track of which element is in what part of the venn diagram, rather than just know the cardinality of that subset.



Anyway, it is my __personal__ opinion that your library would be better off including such converter functions to make it easier to use, especially to those who are not the most familiar with Pandas.
diff --git a/upsetplot/tests/test_data.py b/upsetplot/tests/test_data.py
index f768f05..4e8e17a 100644
--- a/upsetplot/tests/test_data.py
+++ b/upsetplot/tests/test_data.py
@@ -1,10 +1,11 @@
+from collections import OrderedDict
 import pytest
 import pandas as pd
 import numpy as np
 from distutils.version import LooseVersion
 from pandas.util.testing import (assert_series_equal, assert_frame_equal,
                                  assert_index_equal)
-from upsetplot import from_memberships
+from upsetplot import from_memberships, from_contents
 
 
 @pytest.mark.parametrize('typ', [set, list, tuple, iter])
@@ -82,3 +83,95 @@ def test_from_memberships_with_data(data, ndim):
 
     with pytest.raises(ValueError, match='length'):
         from_memberships(memberships[:-1], data=data)
+
+
+@pytest.mark.parametrize('data', [None,
+                                  {'attr1': [3, 4, 5, 6, 7, 8],
+                                   'attr2': list('qrstuv')}])
+@pytest.mark.parametrize('typ', [set, list, tuple, iter])
+@pytest.mark.parametrize('id_column', ['id', 'blah'])
+def test_from_contents_vs_memberships(data, typ, id_column):
+    contents = {'cat1': typ(['aa', 'bb', 'cc']),
+                'cat2': typ(['cc', 'dd']),
+                'cat3': typ(['ee']),
+                }
+    # Note that ff is not present in contents
+    data_df = pd.DataFrame(data,
+                           index=['aa', 'bb', 'cc', 'dd', 'ee', 'ff'])
+    baseline = from_contents(OrderedDict(contents), data=data_df,
+                             id_column=id_column)
+    # compare from_contents to from_memberships
+    expected = from_memberships(memberships=[{'cat1'},
+                                             {'cat1'},
+                                             {'cat1', 'cat2'},
+                                             {'cat2'},
+                                             {'cat3'},
+                                             []],
+                                data=data_df)
+    assert_series_equal(baseline[id_column].reset_index(drop=True),
+                        pd.Series(['aa', 'bb', 'cc', 'dd', 'ee', 'ff'],
+                                  name=id_column))
+    assert_frame_equal(baseline.drop(columns=[id_column]), expected)
+
+
+def test_from_contents(typ=set, id_column='id'):
+    contents = {'cat1': {'aa', 'bb', 'cc'},
+                'cat2': {'cc', 'dd'},
+                'cat3': {'ee'},
+                }
+    empty_data = pd.DataFrame(index=['aa', 'bb', 'cc', 'dd', 'ee'])
+    baseline = from_contents(OrderedDict(contents), data=empty_data,
+                             id_column=id_column)
+    # data=None
+    out = from_contents(OrderedDict(contents), id_column=id_column)
+    assert_frame_equal(out.sort_values(id_column), baseline)
+
+    # unordered contents dict
+    out = from_contents({'cat3': contents['cat3'],
+                         'cat2': contents['cat2'],
+                         'cat1': contents['cat1']},
+                        data=empty_data, id_column=id_column)
+    assert_frame_equal(out.reorder_levels(['cat1', 'cat2', 'cat3']),
+                       baseline)
+
+    # empty category
+    out = from_contents({'cat1': contents['cat1'],
+                         'cat2': contents['cat2'],
+                         'cat3': contents['cat3'],
+                         'cat4': []},
+                        data=empty_data,
+                        id_column=id_column)
+    assert not out.index.to_frame()['cat4'].any()  # cat4 should be all-false
+    assert len(out.index.names) == 4
+    out.index = out.index.to_frame().set_index(['cat1', 'cat2', 'cat3']).index
+    assert_frame_equal(out, baseline)
+
+
+@pytest.mark.parametrize('id_column', ['id', 'blah'])
+def test_from_contents_invalid(id_column):
+    contents = {'cat1': {'aa', 'bb', 'cc'},
+                'cat2': {'cc', 'dd'},
+                'cat3': {'ee'},
+                }
+    with pytest.raises(ValueError, match='columns overlap'):
+        from_contents(contents,
+                      data=pd.DataFrame({'cat1': [1, 2, 3, 4, 5]}),
+                      id_column=id_column)
+    with pytest.raises(ValueError, match='duplicate ids'):
+        from_contents({'cat1': ['aa', 'bb'],
+                       'cat2': ['dd', 'dd']}, id_column=id_column)
+    # category named id
+    with pytest.raises(ValueError, match='cannot be named'):
+        from_contents({id_column: {'aa', 'bb', 'cc'},
+                       'cat2': {'cc', 'dd'},
+                       }, id_column=id_column)
+    # category named id
+    with pytest.raises(ValueError, match='cannot contain'):
+        from_contents(contents,
+                      data=pd.DataFrame({id_column: [1, 2, 3, 4, 5]},
+                                        index=['aa', 'bb', 'cc', 'dd', 'ee']),
+                      id_column=id_column)
+    with pytest.raises(ValueError, match='identifiers in contents'):
+        from_contents({'cat1': ['aa']},
+                      data=pd.DataFrame([[1]]),
+                      id_column=id_column)

pip install -e .[dev]
parse_log_pytest
requirements.txt
numpy>=1.16.0 pandas>=1.0.0 scipy matplotlib numpydoc sphinx-gallery sphinx-issues seaborn scikit-learn pytest
apt-get update apt-get install -y gcc
3.6
doc/requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alabaster==0.7.13
attrs==22.2.0
Babel==2.11.0
certifi==2021.5.30
charset-normalizer==2.0.12
cycler==0.11.0
docutils==0.18.1
idna==3.10
imagesize==1.4.1
importlib-metadata==4.8.3
iniconfig==1.1.1
Jinja2==3.0.3
joblib==1.1.1
kiwisolver==1.3.1
MarkupSafe==2.0.1
matplotlib==3.3.4
numpy==1.19.5
numpydoc==1.1.0
packaging==21.3
pandas==1.1.5
Pillow==8.4.0
pluggy==1.0.0
py==1.11.0
Pygments==2.14.0
pyparsing==3.1.4
pytest==7.0.1
python-dateutil==2.9.0.post0
pytz==2025.2
requests==2.27.1
scikit-learn==0.24.2
scipy==1.5.4
seaborn==0.11.2
six==1.17.0
snowballstemmer==2.2.0
Sphinx==5.3.0
sphinx-gallery==0.10.0
sphinx-issues==3.0.1
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.0
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
threadpoolctl==3.1.0
tomli==1.2.3
typing_extensions==4.1.1
-e git+https://github.com/jnothman/UpSetPlot.git@e66934c2e4f89f6a85b52ded959d8d2f620d9902#egg=UpSetPlot
urllib3==1.26.20
zipp==3.6.0

name: UpSetPlot
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
  - certifi=2021.5.30=py36h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - 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=1.1.1w=h7f8727e_0
  - pip=21.2.2=py36h06a4308_0
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alabaster==0.7.13
      - attrs==22.2.0
      - babel==2.11.0
      - charset-normalizer==2.0.12
      - cycler==0.11.0
      - docutils==0.18.1
      - idna==3.10
      - imagesize==1.4.1
      - importlib-metadata==4.8.3
      - iniconfig==1.1.1
      - jinja2==3.0.3
      - joblib==1.1.1
      - kiwisolver==1.3.1
      - markupsafe==2.0.1
      - matplotlib==3.3.4
      - numpy==1.19.5
      - numpydoc==1.1.0
      - packaging==21.3
      - pandas==1.1.5
      - pillow==8.4.0
      - pluggy==1.0.0
      - py==1.11.0
      - pygments==2.14.0
      - pyparsing==3.1.4
      - pytest==7.0.1
      - python-dateutil==2.9.0.post0
      - pytz==2025.2
      - requests==2.27.1
      - scikit-learn==0.24.2
      - scipy==1.5.4
      - seaborn==0.11.2
      - six==1.17.0
      - snowballstemmer==2.2.0
      - sphinx==5.3.0
      - sphinx-gallery==0.10.0
      - sphinx-issues==3.0.1
      - sphinxcontrib-applehelp==1.0.2
      - sphinxcontrib-devhelp==1.0.2
      - sphinxcontrib-htmlhelp==2.0.0
      - sphinxcontrib-jsmath==1.0.1
      - sphinxcontrib-qthelp==1.0.3
      - sphinxcontrib-serializinghtml==1.1.5
      - threadpoolctl==3.1.0
      - tomli==1.2.3
      - typing-extensions==4.1.1
      - urllib3==1.26.20
      - zipp==3.6.0
prefix: /opt/conda/envs/UpSetPlot

upsetplot/tests/test_data.py::test_from_memberships_no_data[set] upsetplot/tests/test_data.py::test_from_memberships_no_data[list] upsetplot/tests/test_data.py::test_from_memberships_no_data[tuple] upsetplot/tests/test_data.py::test_from_memberships_no_data[iter] upsetplot/tests/test_data.py::test_from_memberships_with_data[data0-1] upsetplot/tests/test_data.py::test_from_memberships_with_data[data1-1] upsetplot/tests/test_data.py::test_from_memberships_with_data[data2-1] upsetplot/tests/test_data.py::test_from_memberships_with_data[data3-2] upsetplot/tests/test_data.py::test_from_memberships_with_data[data4-2] upsetplot/tests/test_data.py::test_from_contents_vs_memberships[id-set-None] upsetplot/tests/test_data.py::test_from_contents_vs_memberships[id-set-data1] upsetplot/tests/test_data.py::test_from_contents_vs_memberships[id-list-None] upsetplot/tests/test_data.py::test_from_contents_vs_memberships[id-list-data1] upsetplot/tests/test_data.py::test_from_contents_vs_memberships[id-tuple-None] upsetplot/tests/test_data.py::test_from_contents_vs_memberships[id-tuple-data1] upsetplot/tests/test_data.py::test_from_contents_vs_memberships[id-iter-None] upsetplot/tests/test_data.py::test_from_contents_vs_memberships[id-iter-data1] upsetplot/tests/test_data.py::test_from_contents_vs_memberships[blah-set-None] upsetplot/tests/test_data.py::test_from_contents_vs_memberships[blah-set-data1] upsetplot/tests/test_data.py::test_from_contents_vs_memberships[blah-list-None] upsetplot/tests/test_data.py::test_from_contents_vs_memberships[blah-list-data1] upsetplot/tests/test_data.py::test_from_contents_vs_memberships[blah-tuple-None] upsetplot/tests/test_data.py::test_from_contents_vs_memberships[blah-tuple-data1] upsetplot/tests/test_data.py::test_from_contents_vs_memberships[blah-iter-None] upsetplot/tests/test_data.py::test_from_contents_vs_memberships[blah-iter-data1] upsetplot/tests/test_data.py::test_from_contents upsetplot/tests/test_data.py::test_from_contents_invalid[id] upsetplot/tests/test_data.py::test_from_contents_invalid[blah]
doc/api.rst upsetplot/data.py upsetplot/__init__.py
doc/api.rst upsetplot/data.py upsetplot/__init__.py